/ 9 min read
Building an iOS Todo App with Tauri 2.0, React, & TypeScript
Building mobile apps doesn’t always require learning platform-specific languages. With Tauri 2.0, you can create native iOS applications using web technologies you already know. In this comprehensive guide, I’ll walk you through building a beautiful, fully-functional todo app that feels right at home on iOS.

Why Tauri for iOS Development?
Tauri 2.0 brings exciting iOS support, allowing developers to leverage their web development skills while creating truly native mobile applications. Unlike hybrid solutions, Tauri apps compile to native code, offering excellent performance and a genuine native feel.
Our tech stack includes:
- Tauri 2.0 for iOS compilation and native features
- React + TypeScript for the frontend
- Vite for fast development
- Zustand for lightweight state management
- Tailwind CSS for styling
- shadcn/ui for beautiful components
Setting Up the Project
Let’s start by creating our Tauri project using the official CLI:
npm create tauri-app@latest todo-app -- --template react-tscd todo-app
Next, we’ll add our essential dependencies:
npm install zustand react-router-dom lucide-react clsx tailwind-mergenpm install -D tailwindcss postcss autoprefixernpx tailwindcss init -p
Configuring Tailwind CSS
Update your tailwind.config.js
to include the paths to your components:
export default { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], darkMode: 'class', theme: { extend: {} }, plugins: []}
Don’t forget to update your PostCSS config to use ES modules:
export default { plugins: { tailwindcss: {}, autoprefixer: {} }}
Building the Core Data Types
Let’s define our todo structure. Create src/types/todo.ts
:
export interface Todo { id: string text: string completed: boolean createdAt: Date completedAt?: Date priority: 'low' | 'medium' | 'high' category: string}
export type FilterType = 'all' | 'active' | 'completed'
State Management with Zustand
Zustand provides a simple, powerful way to manage our app’s state. Create src/stores/todoStore.ts
:
import { create } from 'zustand'import { persist } from 'zustand/middleware'import { Todo, FilterType } from '../types/todo'
interface TodoStore { todos: Todo[] filter: FilterType addTodo: (text: string, category?: string, priority?: 'low' | 'medium' | 'high') => void toggleTodo: (id: string) => void deleteTodo: (id: string) => void setFilter: (filter: FilterType) => void getTodayTodos: () => Todo[] getOverdueTodos: () => Todo[]}
export const useTodoStore = create<TodoStore>()( persist( (set, get) => ({ todos: [], filter: 'all',
addTodo: (text, category = 'Inbox', priority = 'medium') => set((state) => ({ todos: [ ...state.todos, { id: Date.now().toString(), text, completed: false, createdAt: new Date(), priority, category } ] })),
toggleTodo: (id) => set((state) => ({ todos: state.todos.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed, completedAt: !todo.completed ? new Date() : undefined } : todo ) })),
deleteTodo: (id) => set((state) => ({ todos: state.todos.filter((todo) => todo.id !== id) })),
setFilter: (filter) => set({ filter }),
getTodayTodos: () => { const today = new Date() today.setHours(0, 0, 0, 0) return get().todos.filter((todo) => todo.createdAt >= today && !todo.completed) },
getOverdueTodos: () => { const today = new Date() today.setHours(0, 0, 0, 0) return get().todos.filter((todo) => todo.createdAt < today && !todo.completed) } }), { name: 'todo-storage', // Handle Date serialization partialize: (state) => ({ ...state, todos: state.todos.map((todo) => ({ ...todo, createdAt: todo.createdAt.toISOString(), completedAt: todo.completedAt?.toISOString() })) }), onRehydrateStorage: () => (state) => { if (state?.todos) { state.todos = state.todos.map((todo) => ({ ...todo, createdAt: new Date(todo.createdAt as string), completedAt: todo.completedAt ? new Date(todo.completedAt as string) : undefined })) } } } ))
Creating Beautiful UI Components
The Todo Item Component
Let’s create a sleek todo item that feels native to iOS. Create src/components/TodoItem.tsx
:
import React from 'react'import { Check, Trash2 } from 'lucide-react'import { Todo } from '../types/todo'import { useTodoStore } from '../stores/todoStore'
interface TodoItemProps { todo: Todo}
export const TodoItem: React.FC<TodoItemProps> = ({ todo }) => { const { toggleTodo, deleteTodo } = useTodoStore()
const priorityColors = { low: 'bg-green-500', medium: 'bg-yellow-500', high: 'bg-red-500' }
return ( <div className='mb-2 flex items-center gap-3 rounded-lg bg-gray-900 p-4'> <button onClick={() => toggleTodo(todo.id)} className={`flex h-6 w-6 items-center justify-center rounded-full border-2 transition-colors ${ todo.completed ? 'border-blue-500 bg-blue-500' : 'border-gray-400 hover:border-blue-400' }`} > {todo.completed && <Check className='h-4 w-4 text-white' />} </button>
<div className='flex-1'> <p className={`text-white ${todo.completed ? 'line-through opacity-60' : ''}`}> {todo.text} </p> <div className='mt-1 flex items-center gap-2'> <span className='text-sm text-red-400'>{todo.createdAt.toLocaleDateString()}</span> <span className='text-sm text-gray-400'>{todo.category}</span> </div> </div>
<div className={`h-3 w-3 rounded-full ${priorityColors[todo.priority]}`} />
<button onClick={() => deleteTodo(todo.id)} className='p-2 text-gray-400 transition-colors hover:text-red-400' > <Trash2 className='h-4 w-4' /> </button> </div> )}
The Add Todo Form
Create an elegant form for adding new todos in src/components/TodoForm.tsx
:
import React, { useState } from 'react'import { Plus } from 'lucide-react'import { useTodoStore } from '../stores/todoStore'
export const TodoForm: React.FC = () => { const [text, setText] = useState('') const [category, setCategory] = useState('Inbox') const [isOpen, setIsOpen] = useState(false) const { addTodo } = useTodoStore()
const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (text.trim()) { addTodo(text, category) setText('') setIsOpen(false) } }
return ( <> {/* Floating Action Button */} <button onClick={() => setIsOpen(true)} className='fixed bottom-20 right-6 z-10 flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-lg' > <Plus className='h-6 w-6 text-black' /> </button>
{/* Modal Overlay */} {isOpen && ( <div className='fixed inset-0 z-20 flex items-end bg-black bg-opacity-50' onClick={() => setIsOpen(false)} > <div className='w-full space-y-4 rounded-t-3xl bg-gray-800 p-6' onClick={(e) => e.stopPropagation()} > <div className='mx-auto mb-4 h-1 w-12 rounded-full bg-gray-600' />
<form onSubmit={handleSubmit} className='space-y-4'> <div className='flex items-center gap-3'> <div className='h-6 w-6 rounded-full border-2 border-gray-400' /> <input type='text' value={text} onChange={(e) => setText(e.target.value)} placeholder='What needs to be done?' className='flex-1 bg-transparent text-lg text-white outline-none' autoFocus /> </div>
<div className='flex gap-2'> <button type='button' onClick={() => setCategory('Inbox')} className={`rounded-full px-4 py-2 text-sm ${ category === 'Inbox' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300' }`} > Inbox </button> <button type='button' onClick={() => setCategory('Today')} className={`rounded-full px-4 py-2 text-sm ${ category === 'Today' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300' }`} > Today </button> </div>
<button type='submit' disabled={!text.trim()} className='w-full rounded-lg bg-blue-500 py-3 font-medium text-white disabled:opacity-50' > Add Task </button> </form> </div> </div> )} </> )}
Building Navigation Pages
The Main App Structure
Create your main app structure in src/App.tsx
:
import React from 'react'import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'import { Home } from './pages/Home'import { Upcoming } from './pages/Upcoming'import { Search } from './pages/Search'import { Settings } from './pages/Settings'import { Navigation } from './components/Navigation'import { TodoForm } from './components/TodoForm'
function App() { return ( <Router> <div className='font-system min-h-screen bg-black text-white'> <main className='pb-16'> <Routes> <Route path='/' element={<Home />} /> <Route path='/upcoming' element={<Upcoming />} /> <Route path='/search' element={<Search />} /> <Route path='/settings' element={<Settings />} /> </Routes> </main> <Navigation /> <TodoForm /> </div> </Router> )}
export default App
The Home Page
Create the main todo list view in src/pages/Home.tsx
:
import React from 'react'import { TodoItem } from '../components/TodoItem'import { useTodoStore } from '../stores/todoStore'
export const Home: React.FC = () => { const { todos, filter } = useTodoStore() const todayTodos = todos.filter((todo) => !todo.completed) const todayCount = todayTodos.length
return ( <div className='px-4 pb-4 pt-12'> <div className='mb-6'> <h1 className='text-3xl font-bold text-white'>Today</h1> <p className='text-gray-400'> {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} </p> </div>
<div className='space-y-2'> {todayTodos.map((todo) => ( <TodoItem key={todo.id} todo={todo} /> ))}
{todayTodos.length === 0 && ( <div className='py-12 text-center text-gray-400'> <p>No tasks for today!</p> <p className='mt-2 text-sm'>Tap the + button to add a new task</p> </div> )} </div> </div> )}
iOS-Specific Configuration
Tauri Configuration
Update your src-tauri/tauri.conf.json
for iOS. You can find more configuration options in the official documentation:
{ "$schema": "https://schema.tauri.app/config/2", "productName": "todo-app", "version": "0.1.0", "identifier": "com.todoapp.mobile", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" }, "app": { "windows": [ { "title": "todo-app", "width": 375, "height": 812, "resizable": true, "fullscreen": false } ] }}
Package.json Scripts
Add iOS-specific scripts to your package.json
:
{ "scripts": { "ios:init": "npm run tauri ios init", "ios:dev": "npm run tauri ios dev", "ios:build": "npm run tauri ios build", "ios:build:release": "npm run tauri ios build --release" }}
Styling for iOS
Add iOS-specific styles to your src/index.css
:
@tailwind base;@tailwind components;@tailwind utilities;
@layer base { * { -webkit-tap-highlight-color: transparent; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background-color: #000000; color: #ffffff; user-select: none; -webkit-user-select: none; }}
@layer utilities { .font-system { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.safe-area-top { padding-top: env(safe-area-inset-top); }
.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom); }}
@media (max-width: 768px) { .touch-manipulation { touch-action: manipulation; }}
Building and Testing
Initialize iOS Support
First, initialize iOS support for your Tauri project. Make sure you have the required dependencies as outlined in the iOS prerequisites guide:
npm run ios:init
Development
Run your app in development mode:
npm run ios:dev
This will:
- Start the Vite development server
- Build the Rust backend
- Launch the iOS simulator
- Deploy your app to the simulator
Common Issues and Solutions
Simulator Not Starting: If you encounter CoreSimulator errors, restart the simulator service:
sudo killall -9 com.apple.CoreSimulator.CoreSimulatorServiceopen -a Simulator
Product Name Mismatch: Ensure your productName
in tauri.conf.json
matches your build output (use hyphens, not spaces).
Build Failures: Clean your build directory:
rm -rf src-tauri/gen/apple/build
Advanced Features
Adding Haptic Feedback
For a truly native feel, you can add haptic feedback using Tauri’s API:
import { invoke } from '@tauri-apps/api/core'
const triggerHaptic = async () => { try { await invoke('haptic_feedback', { type: 'impact' }) } catch (error) { console.log('Haptic feedback not available') }}
Local Notifications
Implement local notifications for task reminders using the notification plugin:
import { isPermissionGranted, requestPermission, sendNotification} from '@tauri-apps/plugin-notification'
const scheduleNotification = async (todo: Todo) => { let permissionGranted = await isPermissionGranted()
if (!permissionGranted) { const permission = await requestPermission() permissionGranted = permission === 'granted' }
if (permissionGranted) { sendNotification({ title: 'Todo Reminder', body: todo.text }) }}
Production Build
When you’re ready to build for production:
npm run ios:build:release
This creates an optimized build ready for App Store submission.
Conclusion
Building iOS apps with Tauri 2.0 opens up exciting possibilities for web developers. You can leverage your existing React and TypeScript skills while creating truly native mobile experiences. The combination of modern web technologies with native iOS capabilities provides the best of both worlds.
Key takeaways:
- Tauri 2.0 enables native iOS development with web technologies
- Proper configuration is crucial for iOS deployment
- Zustand provides excellent state management for mobile apps
- iOS-specific styling creates authentic mobile experiences
- The development workflow is smooth once properly set up
The future of cross-platform mobile development is bright, and Tauri is leading the way in making native mobile development accessible to web developers everywhere.
Ready to build your own iOS app with Tauri? Start with this todo app template and customize it to fit your needs. The possibilities are endless!