Building a Modern Todo App with Zustand, Firebase, and Next.js
Mudasir FayazA Todo App might seem simple, but it’s one of the best projects to master the art of building scalable, fullstack web applications. In this tutorial, we’ll build a modern Todo app using Next.js for the frontend and routing, Firebase for real-time data and authentication, and Zustand for elegant state management.
Why This Stack?
- ⚡ Next.js – Provides server-side rendering, app routing, and deployment ease.
- 🔥 Firebase – Real-time database, auth, and hosting without managing servers.
- 🧠 Zustand – Minimal, powerful, and predictable state management.
“Combining Firebase’s simplicity with Zustand’s minimalism and Next.js’s performance creates a perfect developer workflow.”
Step 1: Setup the Next.js Project
Let’s start with a new Next.js app using TypeScript:
npx create-next-app@latest modern-todo --typescript
cd modern-todo
npm install firebase zustandOnce installed, you’ll have a ready TypeScript-based Next.js app to work with.
Step 2: Configure Firebase
Head over to the Firebase Console, create a new project, and enable the Firestore Database andAuthentication (Email/Password).
Then, get your Firebase config and add it to an environment file:
NEXT_PUBLIC_FIREBASE_API_KEY=xxxxxx
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=xxxxxx
NEXT_PUBLIC_FIREBASE_PROJECT_ID=xxxxxx
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=xxxxxx
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=xxxxxx
NEXT_PUBLIC_FIREBASE_APP_ID=xxxxxxStep 3: Initialize Firebase in the Project
Create a new file to initialize Firebase and export its services:
// lib/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);This connects your Next.js app to Firebase’s Firestore and Authentication services.
Step 4: Manage State with Zustand
Now, let’s set up Zustand to manage our todos and app state globally:
// store/todoStore.ts
import { create } from 'zustand';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoStore {
todos: Todo[];
addTodo: (todo: Todo) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
setTodos: (todos: Todo[]) => void;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
setTodos: (todos) => set({ todos }),
}));Zustand provides a lightweight and reactive state layer — no reducers or boilerplate needed.
Step 5: Create the Todo UI
Let’s design a simple yet elegant UI using Tailwind CSS to add, display, and manage todos:
// components/TodoApp.tsx
'use client';
import { useState, useEffect } from 'react';
import { useTodoStore } from '@/store/todoStore';
import { db } from '@/lib/firebase';
import { collection, addDoc, onSnapshot, deleteDoc, doc, updateDoc } from 'firebase/firestore';
import { v4 as uuid } from 'uuid';
export default function TodoApp() {
const { todos, setTodos, addTodo, toggleTodo, removeTodo } = useTodoStore();
const [input, setInput] = useState('');
useEffect(() => {
const unsubscribe = onSnapshot(collection(db, 'todos'), (snapshot) => {
const todoList = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
setTodos(todoList as any);
});
return () => unsubscribe();
}, [setTodos]);
const handleAdd = async () => {
if (!input) return;
const newTodo = { id: uuid(), text: input, completed: false };
await addDoc(collection(db, 'todos'), newTodo);
addTodo(newTodo);
setInput('');
};
return (
<div className="max-w-md mx-auto mt-10 bg-white p-6 rounded-xl shadow-md">
<h2 className="text-2xl font-semibold text-center mb-4">My Todo List</h2>
<div className="flex gap-2 mb-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add a new task..."
className="flex-1 border rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition"
>
Add
</button>
</div>
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo.id}
className="flex justify-between items-center bg-gray-50 p-3 rounded-md shadow-sm"
>
<span
onClick={() => toggleTodo(todo.id)}
className={`cursor-pointer ${todo.completed ? 'line-through text-gray-400' : 'text-gray-800'}`}
>
{todo.text}
</span>
<button
onClick={() => removeTodo(todo.id)}
className="text-red-500 hover:text-red-700"
>
✕
</button>
</li>
))}
</ul>
</div>
);
}This app supports **real-time updates**, **persistent storage**, and **state sync** — all in under 150 lines of code.
Step 6: Deploying to Vercel
Finally, push your code to GitHub and deploy to Vercel. Add your Firebase credentials as environment variables, and your live Todo app will be instantly available worldwide.
Conclusion
With just Next.js, Firebase, and Zustand, we’ve built a complete fullstack app — featuring real-time data, simple global state management, and elegant UI design. This stack is perfect for small-scale SaaS apps, productivity tools, or learning projects.
By combining these three tools, developers get the best of all worlds: speed, simplicity, and scalability.