Login

Next.js Todo App with Firebase

J
Jonathan Gamble
Published Updated 24 min read


TL;DR#

Build a Next.js App Router todo app with React context and hooks, Google authentication, realtime Firestore, Tailwind CSS, and a server-rendered About page.

Setup#

Use Node.js 24 or newer. Install the dependencies:

Bash
npm install

The repository includes Next.js 16, React 19, Firebase, Valibot, and Tailwind CSS 4. React hooks connect directly to the modular Firebase SDK.

Firebase Setup#

Create a Firebase project, register a web app, enable Google sign-in, and create a Firestore database. Configure Authentication's authorized domains for localhost and your deployed hostname.

Copy .env.example to .env.local and enter your Firebase web app configuration:

Dotenv
NEXT_PUBLIC_FIREBASE_CONFIG='{"apiKey":"...","authDomain":"...","projectId":"...","appId":"..."}'

Next.js embeds public environment variables in browser code at build time. Provide this configuration before building and restart development after changing it. Firestore rules enforce access; never include service-account secrets in public variables.

Deploy the supplied rules and index separately from the Next.js build. Create about/ZlNJrKd6LcATycPRmBPA with string fields name and description. The server reads that document without the browser's authentication session, so its rules allow public reads.

Data Types#

  • UserType and UserState live in src/lib/use-user.tsx.
  • TodoDoc lives in src/lib/use-todos.tsx.
  • Valibot validates the About document and infers its return type in src/lib/about.ts.

Todos store uid, text, complete, and createdAt. Reads add the document ID and convert the timestamp to a JavaScript date. Completion updates also write updatedAt.

src/lib/firebase.ts#

Initialize shared authentication and realtime Firestore clients. Reuse the default Firebase app during module re-evaluation.

TypeScript
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

const firebase_config = JSON.parse(
    process.env.NEXT_PUBLIC_FIREBASE_CONFIG!
);

// initialize and login

export const app = getApps().length
    ? getApp()
    : initializeApp(firebase_config);

export const auth = getAuth(app);
export const db = getFirestore(app);

Authentication#

How the Auth State Is Shared#

UserProvider owns React state and shares it through context. Its browser effect subscribes to token changes and returns the unsubscribe function. useUser reads that context; state changes rerender consumers. These hooks replace the Solid store and reactive accessors.

src/lib/use-user.tsx#

The client directive enables browser authentication and React hooks. Keep only the user fields needed by the UI. Login and logout return recognized Firebase errors and rethrow unexpected failures.

TSX
"use client";

import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { FirebaseError } from "firebase/app";
import { GoogleAuthProvider, onIdTokenChanged, signInWithPopup, signOut } from "firebase/auth";
import { auth } from "./firebase";

export type UserType = {
  displayName: string | null;
  photoURL: string | null;
  uid: string;
  email: string | null;
};

export type UserState = {
  loading: boolean;
  data: UserType | null;
};

const UserContext = createContext<UserState | null>(null);

export function UserProvider({ children }: { children: ReactNode }) {
  const [getUser, setUser] = useState<UserState>({ loading: true, data: null });

  useEffect(() => onIdTokenChanged(auth, (user) => {
    if (!user) {
      setUser({ loading: false, data: null });
      return;
    }
    const { displayName, photoURL, uid, email } = user;
    setUser({ loading: false, data: { displayName, photoURL, uid, email } });
  }), []);

  return <UserContext value={getUser}>{children}</UserContext>;
}

export function useUser() {
  const getUser = useContext(UserContext);
  if (!getUser) throw new Error("useUser requires a UserProvider.");
  return getUser;
}

export const loginWithGoogle = async () => {
  try {
    await signInWithPopup(auth, new GoogleAuthProvider());
    return { error: null };
  } catch (error) {
    if (error instanceof FirebaseError) {
      return { error: error.message };
    }
    throw error;
  }
};

export const logout = async () => {
  try {
    await signOut(auth);
    return { error: null };
  } catch (error) {
    if (error instanceof FirebaseError) {
      return { error: error.message };
    }
    throw error;
  }
};

src/lib/helpers.tsx#

Share loading, sign-in, and logout controls. These basic buttons invoke authentication actions but do not display their returned errors.

TSX
"use client";

import { loginWithGoogle, logout } from "./use-user";

export const Loading = () => <p>Loading...</p>;

export const Login = () => (
  <button
    type="button"
    className="p-2 text-white bg-red-600 border rounded-md"
    onClick={() => loginWithGoogle()}
  >
    Sign in with Google
  </button>
);

export const Logout = () => (
  <p>
    <button
      type="button"
      className="p-2 text-white border rounded-md bg-lime-600"
      onClick={() => logout()}
    >
      Logout
    </button>
  </p>
);

src/components/home.tsx#

Choose loading, profile, or login from the context value. React JSX uses className for CSS classes.

TSX
"use client";

import { Loading, Login } from "@/lib/helpers";
import { useUser } from "@/lib/use-user";
import Profile from "./profile";

export default function Home() {
    const user = useUser();
    return (
        <div className="text-center">
            <h1 className="text-3xl font-semibold my-3">Next.js Firebase Todo App</h1>
            {user.loading ? <Loading /> : user.data ? <Profile /> : <Login />}
        </div>
    );
}

src/components/profile.tsx#

Display the user, optional avatar, sign-out button, and todos. Home imports this module into its client tree. The next/image avatar hostname is configured below.

TSX
import { Logout } from "@/lib/helpers";
import { useUser } from "@/lib/use-user";
import Image from 'next/image'
import Todos from "./todos";

export default function Profile() {

    const user = useUser();

    if (!user.data) {
        return;
    }

    const { displayName, photoURL, uid } = user.data;

    return (
        <div className="flex flex-col gap-3 items-center">
            <h3 className="font-bold">Hi {displayName}!</h3>
            {photoURL &&
                <Image src={photoURL} width="100" height="100" alt="user avatar" />
            }
            <p>Your userID is {uid}</p>
            <Logout />
            <Todos />
        </div>
    );
}

Todos#

Subscribe to tasks filtered by the current UID and ordered by createdAt. Firestore rules enforce ownership independently of this filter.

src/lib/use-todos.tsx#

The effect depends on the UID and returns the snapshot unsubscribe function. React cleans up when the user changes or the component unmounts. Snapshot state records its UID, so the hook hides the previous account data during account changes and clears the visible list on sign-out. The converter estimates pending server timestamps and converts them to dates. Listener errors belong to the list; write helpers return action errors. Creation trims text and uses a server timestamp.

TSX
"use client";

import { useEffect, useState } from "react";
import {
  collection,
  deleteDoc,
  doc,
  FirestoreError,
  onSnapshot,
  orderBy,
  query,
  serverTimestamp,
  setDoc,
  Timestamp,
  type FirestoreDataConverter,
  updateDoc,
  where,
} from "firebase/firestore";
import { auth, db } from "./firebase";
import { useUser } from "./use-user";

export type TodoDoc = {
  id: string;
  uid: string;
  text: string;
  complete: boolean;
  createdAt: Date;
};

const todoConverter: FirestoreDataConverter<TodoDoc> = {
  toFirestore(todo) {
    return todo;
  },

  fromFirestore(snapshot) {
    const data = snapshot.data({ serverTimestamps: "estimate" });
    const createdAt = data.createdAt as Timestamp;

    return {
      id: snapshot.id,
      uid: data.uid,
      text: data.text,
      complete: data.complete,
      createdAt: createdAt.toDate(),
    };
  },
};

export const useTodos = () => {
  const user = useUser();
  const uid = user.data?.uid;
  const [todos, setTodos] = useState<{
    data: TodoDoc[];
    loading: boolean;
    error: string | null;
    uid: string | undefined;
  }>({ data: [], loading: true, error: null, uid: undefined });

  useEffect(() => {
    if (!uid) return;

    return onSnapshot(
      query(
        collection(db, "todos"),
        where("uid", "==", uid),
        orderBy("createdAt"),
      ).withConverter(todoConverter),
      (snapshot) => setTodos({
        data: snapshot.docs.map((item) => item.data()),
        loading: false,
        error: null,
        uid,
      }),
      (error) => setTodos({ data: [], loading: false, error: error.message, uid }),
    );
  }, [uid]);

  // Never expose a previous user's data while a new subscription starts.
  if (!uid) return { data: [], loading: false, error: null };
  if (todos.uid !== uid) return { data: [], loading: true, error: null };
  return todos;
};

export const addTodo = async (text: string) => {
  const user = auth.currentUser;

  if (!user) return { error: "No user" };
  if (!text.trim()) return { error: "Enter a task." };

  try {
    await setDoc(doc(collection(db, "todos")), {
      uid: user.uid,
      text: text.trim(),
      complete: false,
      createdAt: serverTimestamp(),
    });
    return { error: null };
  } catch (error) {
    if (error instanceof FirestoreError) {
      return { error: error.message };
    }
    throw error;
  }
};

export const updateTodo = async (id: string, complete: boolean) => {
  try {
    await updateDoc(doc(db, "todos", id), {
      complete,
      updatedAt: serverTimestamp(),
    });
    return { error: null };
  } catch (error) {
    if (error instanceof FirestoreError) {
      return { error: error.message };
    }
    throw error;
  }
};

export const deleteTodo = async (id: string) => {
  try {
    await deleteDoc(doc(db, "todos", id));
    return { error: null };
  } catch (error) {
    if (error instanceof FirestoreError) {
      return { error: error.message };
    }
    throw error;
  }
};

src/components/todos.tsx#

Render tasks with map and stable document IDs as keys. TodoForm stores input and error with useState. Generate the initial example after mounting to keep server and initial browser output consistent. Creating a document reference generates an ID without writing data. Reset the example only after a successful submission.

TSX
"use client";

import { useEffect, useState, type FormEvent } from "react";
import { collection, doc } from "firebase/firestore";
import { db } from "@/lib/firebase";
import { addTodo, useTodos } from "@/lib/use-todos";
import { Todo } from "./todo-item";

export const generateText = () =>
  doc(collection(db, "todos")).id.substring(0, 10).toLowerCase();

export default function Todos() {
  const todos = useTodos();

  return (
    <div>
      <div className="flex flex-col gap-3">
        {todos.loading ? (
          <p>Loading todos...</p>
        ) : todos.error ? (
          <p role="alert" className="text-red-600">{todos.error}</p>
        ) : (
          todos.data.length ? todos.data.map((todo) => (
            <Todo key={todo.id} todo={todo} />
          )) : <p><b>Add your first todo item!</b></p>
        )}
      </div>
      <TodoForm />
    </div>
  );
}

export function TodoForm() {
  const [text, setText] = useState("");
  const [error, setError] = useState<string | null>(null);

  // Generate only in the browser so the server and first client render agree.
  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setText(generateText());
  }, []);

  const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setError(null);

    const result = await addTodo(text);
    setError(result.error);

    if (!result.error) setText(generateText());
  };

  return (
    <div className="mt-5">
      <form className="flex items-center justify-center gap-3" onSubmit={onSubmit}>
        <input
          className="p-2 border rounded-lg"
          aria-label="New task"
          value={text}
          onChange={(event) => setText(event.currentTarget.value)}
          required
        />
        <button className="p-2 font-semibold text-white bg-purple-600 border rounded-lg" type="submit">
          Add Task
        </button>
      </form>
      {error && (
        <p role="alert" className="mt-2 text-center text-red-600">{error}</p>
      )}
    </div>
  );
}

src/components/todo-item.tsx#

Each item stores its own update and delete errors. Toggle completion or delete the document; successful writes appear through the realtime listener.

TSX
"use client";

import { useState } from "react";
import { deleteTodo, updateTodo, type TodoDoc } from "@/lib/use-todos";

export function Todo(props: { todo: TodoDoc }) {
  const [error, setError] = useState<string | null>(null);

  const toggleComplete = async () => {
    const result = await updateTodo(props.todo.id, !props.todo.complete);
    setError(result.error);
  };

  const handleDelete = async () => {
    const result = await deleteTodo(props.todo.id);
    setError(result.error);
  };

  return (
    <div className="grid grid-cols-[auto_auto_auto_auto] gap-3 items-center justify-items-start">
      <span className={props.todo.complete ? "line-through text-green-700" : ""}>
        {props.todo.text}
      </span>
      <span className={props.todo.complete ? "line-through text-green-700" : ""}>
        {props.todo.id}
      </span>
      <button type="button" aria-label="Toggle task completion" onClick={toggleComplete}>
        {props.todo.complete ? "✔️" : "❌"}
      </button>
      <button type="button" aria-label="Delete task" onClick={handleDelete}>
        🗑
      </button>
      {error && (
        <p role="alert" className="col-span-4 text-red-600">{error}</p>
      )}
    </div>
  );
}

Server-side Firestore#

The About route uses Firestore Lite for a one-time server read. This web SDK still follows security rules and has no Admin SDK privileges or inherited browser session.

src/lib/firebase-lite.ts#

The server-only import prevents client components from importing this module. Reuse the default Firebase app for the Lite client.

TypeScript
import "server-only";
import { getApp, getApps, initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore/lite";

const config = JSON.parse(process.env.NEXT_PUBLIC_FIREBASE_CONFIG!);

const serverApp = getApps().length
  ? getApp()
  : initializeApp(config);

export const serverDB = getFirestore(serverApp);

src/lib/about.ts#

Fetch and validate name and description with Valibot. Missing documents and malformed data throw errors.

TypeScript
import "server-only";
import { doc, getDoc } from "firebase/firestore/lite";
import * as v from "valibot";
import { serverDB } from "./firebase-lite";

const AboutDocSchema = v.object({
  name: v.string(),
  description: v.string(),
});

export async function getAbout() {
  const snapshot = await getDoc(doc(serverDB, "about/ZlNJrKd6LcATycPRmBPA"));

  if (!snapshot.exists()) {
    throw new Error("About document does not exist.");
  }

  const result = v.safeParse(AboutDocSchema, snapshot.data());

  if (!result.success) {
    throw new Error("Malformed About document.");
  }

  return result.output;
}

src/app/about/page.tsx#

An async Server Component awaits the helper directly. force-dynamic renders this page at request time rather than during the build. Metadata supplies the title. This replaces the SolidStart server query, route preload, and createAsync flow.

TSX
import { getAbout } from "@/lib/about";

export const metadata = { title: "About" };

export const dynamic = "force-dynamic";

export default async function AboutPage() {

    const about = await getAbout();

    return (
        <div className="flex items-center justify-center my-5">
        <div className="border w-[400px] p-5 flex flex-col gap-3">
            <h1 className="text-3xl font-semibold">{about.name}</h1>
            <p>{about.description}</p>
        </div>
    </div>
    )
}

App Structure#

App Router discovers routes in src/app. A page.tsx defines a page; layout.tsx wraps descendants. Components are Server Components by default. A client directive introduces the interactive module tree. The @/* alias resolves to src/*.

src/app/page.tsx#

Wrap Home with UserProvider so profile and todo descendants can consume authentication state. The page remains a Server Component while rendering client components.

TSX
import Home from "@/components/home";
import { UserProvider } from "@/lib/use-user";

const HomePage = () => {
    return (
        <main>
        <UserProvider>
            <Home />
        </UserProvider>
        </main>
    );
};

export default HomePage;

src/app/layout.tsx#

Provide the HTML document, global stylesheet, default metadata, and navigation with next/link.

TSX
import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";

export const metadata: Metadata = {
  title: "Next.js Firebase Todo App",
  description: "Sign in with Google and manage your realtime Firebase todo list with Next.js.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        {children}
        <nav className="flex gap-3 justify-center mt-5">
          <Link href="/">Home</Link>
          <Link href="/about">About</Link>
        </nav>
      </body>
    </html>
  );
}

Build Configuration#

Next.js uses its own configuration and build commands in place of Vite and Nitro.

next.config.ts#

Allow the Google avatar hostname for next/image.

TypeScript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "lh3.googleusercontent.com" }],
  },
};

export default nextConfig;

postcss.config.mjs#

Tailwind CSS 4 runs through its PostCSS plugin.

JavaScript
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;

src/app/globals.css#

Load Tailwind through the stylesheet imported by the root layout.

CSS
@import "tailwindcss";

Run the App#

Bash
npm run dev

Open http://localhost:3000. Sign in, add a task, toggle completion, and delete it. Use another signed-in tab to check realtime updates. Sign out and verify the private list disappears. Visit /about to check the server-loaded document.

Stop the development server with Ctrl+C when finished.

Check and Build#

Bash
npm run lint
npm run typecheck
npm run build

Typecheck generates Next.js types before running TypeScript. Supply the Firebase environment configuration before building. After a successful build, preview production locally:

Bash
npm start

Stop the production server with Ctrl+C when finished.

And that's it!

J

Comments

Share a thought or join the conversation.

Sign In to comment or reply.