DEV Community

Sufal Thakre
Sufal Thakre

Posted on

Building a Real-Time Task Manager with Next.js and Firebase - Lessons from the Trenches

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

What happens here:

  1. Query tasks for current user
  2. onSnapshot listens for changes
  3. Any update triggers automatic re-render
  4. 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));
Enter fullscreen mode Exit fullscreen mode

Clean and straightforward.


Part 4: The Composite Index Challenge

When I combined filtering and sorting:

where('userId', '==', user.uid)
orderBy('createdAt', 'desc')
Enter fullscreen mode Exit fullscreen mode

Firebase said: "Nope. You need a composite index."

The error:

FirebaseError: The query requires an index.
You can create it here: [link]
Enter fullscreen mode Exit fullscreen mode

The solution:

  1. Click the provided link
  2. Firebase Console opens
  3. Shows required index fields
  4. Click "Create Index"
  5. Wait 2-3 minutes
  6. 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;
  });
};
Enter fullscreen mode Exit fullscreen mode

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)