Building a Real-Time Task Manager with Next.js and Firebase - Lessons from the Trenches
I recently built a task management app to learn Firebase properly. Not by watching tutorials, but by actually building something.
This article shares what I learned, the mistakes I made, and the solutions I found.
What We're Building
A task manager where users can:
- Sign up and log in securely
- Create, edit, and delete tasks
- See real-time updates across devices
- Filter tasks by status
- Sort by due date
- Access only their own data
Tech Stack:
- Next.js 14 (App Router)
- TypeScript
- Firebase (Authentication + Firestore)
- Tailwind CSS
Final Result: Live Demo
The Architecture
User → Next.js App → Firebase Auth → Protected Routes
↓
Firestore DB
↓
Security Rules
↓
Real-time Listeners
↓
Auto-Update UI
Simple. No backend server needed. Everything client-side with Firebase handling the heavy lifting.
Part 1: Firebase Setup
Initialize Firebase
// lib/firebase.ts
import { initializeApp, getApps } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
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 = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
export const auth = getAuth(app);
export const db = getFirestore(app);
Pro tip: Check if app already exists before initializing. Prevents errors in dev mode with hot reloading.
Part 2: Authentication
Custom Hook for Auth State
// lib/hooks/useAuth.ts
import { useEffect, useState } from 'react';
import { User, onAuthStateChanged } from 'firebase/auth';
import { auth } from '../firebase';
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return unsubscribe; // Cleanup on unmount
}, []);
return { user, loading };
}
This hook:
- Listens for auth state changes
- Returns current user
- Handles loading state
- Cleans up properly
Login/Signup Component
import { createUserWithEmailAndPassword, signInWithEmailAndPassword } from 'firebase/auth';
import { auth } from '@/lib/firebase';
const handleSignup = async (email: string, password: string) => {
try {
await createUserWithEmailAndPassword(auth, email, password);
// User automatically logged in
} catch (error) {
console.error('Signup error:', error);
}
};
const handleLogin = async (email: string, password: string) => {
try {
await signInWithEmailAndPassword(auth, email, password);
// User logged in
} catch (error) {
console.error('Login error:', error);
}
};
That's it. Firebase handles sessions, tokens, everything.
Part 3: Real-Time Database
The Data Model
interface Task {
id: string;
title: string;
description: string;
dueDate: string;
status: 'todo' | 'in-progress' | 'done';
userId: string; // Links to user
createdAt: string;
}
Real-Time Listener
This is where Firebase shines:
useEffect(() => {
if (!user) return;
const q = query(
collection(db, 'tasks'),
where('userId', '==', user.uid),
orderBy('createdAt', 'desc')
);
const unsubscribe = onSnapshot(q, (snapshot) => {
const tasksData: Task[] = [];
snapshot.forEach((doc) => {
tasksData.push({ id: doc.id, ...doc.data() } as Task);
});
setTasks(tasksData);
});
return () => unsubscribe();
}, [user]);
What happens here:
- Query tasks for current user
- onSnapshot listens for changes
- Any update triggers automatic re-render
- Works across all devices in real-time
No polling. No manual refresh. Just works.
CRUD Operations
// CREATE
await addDoc(collection(db, 'tasks'), {
title: 'New Task',
description: 'Task description',
dueDate: '2026-02-01',
status: 'todo',
userId: user.uid,
createdAt: new Date().toISOString()
});
// UPDATE
await updateDoc(doc(db, 'tasks', taskId), {
status: 'done'
});
// DELETE
await deleteDoc(doc(db, 'tasks', taskId));
Clean and straightforward.
Part 4: The Composite Index Challenge
When I combined filtering and sorting:
where('userId', '==', user.uid)
orderBy('createdAt', 'desc')
Firebase said: "Nope. You need a composite index."
The error:
FirebaseError: The query requires an index.
You can create it here: [link]
The solution:
- Click the provided link
- Firebase Console opens
- Shows required index fields
- Click "Create Index"
- Wait 2-3 minutes
- Done
Index structure:
- Collection:
tasks - Fields:
userId(Ascending),createdAt(Descending)
Why it matters: NoSQL databases optimize queries through indexes. Firebase makes you think about performance from the start.
Part 5: Filter & Sort Implementation
const getFilteredAndSortedTasks = () => {
let filtered = tasks;
// Filter by status
if (statusFilter !== 'all') {
filtered = filtered.filter((task) => task.status === statusFilter);
}
// Sort by due date
return filtered.sort((a, b) => {
const dateA = new Date(a.dueDate).getTime();
const dateB = new Date(b.dueDate).getTime();
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
});
};
Client-side filtering for better UX. Real-time updates make this instant.
Lessons Learned
1. Real-Time is a Game Changer
Once you build with real-time, regular APIs feel slow. Users expect instant updates.
2. Security Rules Are Non-Negotiable
Never trust the frontend. Always enforce security at the database level.
3. Error Messages Can Teach
Firebase errors include solutions. Read them carefully.
4. NoSQL ≠ SQL
Different mental model. Different patterns. Both valid.
5. Firebase Isn't Perfect for Everything
Great for MVPs, real-time apps, solo devs.
Not ideal for complex custom logic or existing SQL needs.
Performance Considerations
Query Optimization:
- Always filter at database level
- Use indexes for complex queries
- Limit results when possible
Read Minimization:
- Cache data client-side when appropriate
- Use real-time listeners efficiently
- Unsubscribe when components unmount
Cost Management:
- Monitor Firebase usage
- Optimize queries to reduce reads
- Consider pagination for large datasets
Links
Live Demo: https://task-manager-nextjs-firebase.vercel.app
Source Code: GitHub Repository
Fork it. Break it. Learn from it.
What's Next
Planning to add:
- Cloud Functions for complex logic
- Offline persistence
- File uploads with Firebase Storage
- Push notifications
The journey continues.
Conclusion
Firebase changed how I think about building apps.
It's not just a database. It's a different paradigm.
Real-time by default. Security at database level. No server management.
Perfect? No.
Useful? Absolutely.
Worth learning? 100%.
Questions? Suggestions? Drop them in the comments!
Building something with Firebase? I'd love to see it.
Tags: #firebase #nextjs #tutorial #webdev #javascript #typescript
Top comments (0)