React Router Todo App with Firebase
TL;DR#
Build a React Router framework todo app with React hooks and context, Google authentication, realtime Firestore, Tailwind CSS, and a server-rendered About page.
Setup#
Use Node.js 24.x for this project. Install the dependencies:
npm install
The repository uses React Router 8, React 19, Firebase 13, Valibot, TypeScript, Vite, 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. Add localhost and your deployed hostname to Authentication's authorized domains.
Copy .env.example to .env and enter your Firebase web app configuration:
VITE_FIREBASE_CONFIG='{"apiKey":"...","authDomain":"...","projectId":"...","appId":"..."}'
Vite loads .env and .env.local automatically. Variables prefixed with VITE_ are embedded into the browser bundle at build time. Provide the configuration before building, and restart development or rebuild production after changing it. This is public web configuration; never include service-account secrets.
Configure Firestore rules separately from the application build. Todo reads, updates, and deletes must require an authenticated user whose UID matches the stored uid. Creation must require the submitted uid to match the authenticated user, and updates must prevent ownership changes. A client-side query does not secure the database.
Create a composite index for collection todos with uid ascending and createdAt ascending. A missing-index error includes a link to create the required index.
Create about/ZlNJrKd6LcATycPRmBPA with string fields name and description. Allow public reads of that document because the server loader does not inherit browser authentication. Restrict writes to your intended administrative workflow.
This repository does not include deployable Firestore rules or index files; configure these in Firebase before testing.
Data Types#
- UserType and UserState live in
app/lib/use-user.tsx. - TodoDoc lives in
app/lib/use-todos.tsx. - Valibot validates the About document and infers its return type in
app/lib/about.server.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.
app/lib/firebase.ts#
Initialize the shared Firebase app, Authentication, and realtime Firestore clients. Reuse the default app during module reloads.
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
const firebase_config = JSON.parse(
import.meta.env.VITE_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 shares authentication through React context. Its browser effect subscribes to token changes and returns the unsubscribe function. Consumers rerender when state changes. React Router needs no "use client" directive; effects run after hydration.
app/lib/use-user.tsx#
Keep only the profile fields used by the UI. Login and logout return recognized Firebase errors and rethrow unexpected failures. The provider syntax uses React 19.
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;
}
};
app/lib/helpers.tsx#
Share loading, login, and logout controls. These basic auth buttons do not display returned authentication errors.
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>
);
app/components/home.tsx#
Choose loading, profile, or login from the context value.
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">React Router Firebase Todo App</h1>
{user.loading ? <Loading /> : user.data ? <Profile /> : <Login />}
</div>
);
}
app/components/profile.tsx#
Display the profile, optional avatar, logout button, and todos. A native img with explicit dimensions needs no framework image configuration.
import { Logout } from "~/lib/helpers";
import { useUser } from "~/lib/use-user";
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 &&
<img 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.
app/lib/use-todos.tsx#
The effect depends on the UID and returns the listener cleanup function. Snapshot state records its UID to hide previous account data during account changes. The converter estimates pending 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. generateText creates an ID without writing a document.
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 generateText = () =>
doc(collection(db, "todos")).id.substring(0, 10).toLowerCase();
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;
}
};
app/components/todos.tsx#
Render tasks with stable document IDs as keys. The form tracks input and errors with React state and resets text only after a successful write. Here Todos mounts after browser authentication resolves, so its random initial text is absent from the server render. If you render the form during SSR, initialize text to an empty string and generate the example after mounting to avoid a hydration mismatch. SubmitEvent is the React event type used in this repository.
import { useState, type SubmitEvent } from "react";
import { addTodo, generateText, useTodos } from "~/lib/use-todos";
import { Todo } from "./todo-item";
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(generateText);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: SubmitEvent<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>
);
}
app/components/todo-item.tsx#
Each row stores its own update and delete errors. Successful writes appear through the realtime listener.
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 obeys security rules and has no Admin SDK privileges or inherited browser authentication session.
app/lib/firebase-lite.server.ts#
The .server.ts suffix prevents this module from entering the browser module graph. Reuse the default Firebase app for the Lite client.
import { getApp, getApps, initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore/lite";
const config = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG);
const serverApp = getApps().length
? getApp()
: initializeApp(config);
export const serverDB = getFirestore(serverApp);
app/lib/about.server.ts#
Fetch and validate name and description with Valibot. Missing documents and malformed data throw errors.
import { doc, getDoc } from "firebase/firestore/lite";
import * as v from "valibot";
import { serverDB } from "./firebase-lite.server";
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;
}
app/routes/about.tsx#
A server loader awaits the helper and passes its result to the component through loaderData. React Router generates Route.ComponentProps. SSR with no prerender configuration loads the document at request time; client navigation invokes the server loader through React Router. The meta export provides the page title.
import type { Route } from "./+types/about";
import { getAbout } from "~/lib/about.server";
export const meta: Route.MetaFunction = () => [{ title: "About" }];
export const loader = async () => {
const about = await getAbout();
return about;
};
export default function AboutPage({ loaderData: about }: Route.ComponentProps) {
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#
Declare routes explicitly in app/routes.ts. The root module provides the document layout and an Outlet for the matching route. The ~/* alias resolves to app/*. Ordinary React components hydrate in the browser; server-only helpers use .server.ts filenames.
app/routes.ts#
Register the home index route and /about. The index() declaration makes _index.tsx the index route.
import { index, route, type RouteConfig } from "@react-router/dev/routes";
export default [index("routes/_index.tsx"), route("about", "routes/about.tsx")] satisfies RouteConfig;
app/routes/_index.tsx#
Wrap Home with UserProvider so profile and todo descendants can consume authentication. The provider belongs to the home route, so About does not start an auth listener. Export the home metadata here.
import type { Route } from "./+types/_index";
import Home from "~/components/home";
import { UserProvider } from "~/lib/use-user";
export const meta: Route.MetaFunction = () => [
{ title: "React Router Firebase Todo App" },
{ name: "description", content: "Sign in with Google and manage your realtime Firebase todo list with React Router." },
];
export default function Index() {
return <main><UserProvider><Home /></UserProvider></main>;
}
app/root.tsx#
Provide the HTML document, global stylesheet, metadata, navigation, scroll restoration, and hydration scripts. Link uses to for destinations; Outlet renders the matched route.
import { Links, Meta, Outlet, Scripts, ScrollRestoration, Link } from "react-router";
import type { ReactNode } from "react";
import type { Route } from "./+types/root";
import stylesheet from "~/tailwind.css?url";
export const links: Route.LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];
export function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<nav className="flex gap-3 justify-center mt-5">
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
Build Configuration#
React Router integrates with Vite for development and production builds. Tailwind CSS 4 runs through its Vite plugin.
react-router.config.ts#
Enable SSR without prerendering. About data is read at request time rather than during the build.
import type { Config } from "@react-router/dev/config";
export default { ssr: true } satisfies Config;
vite.config.ts#
Register the Tailwind and React Router plugins and resolve ~ to app. tsconfig.json also maps ~/* to ./app/* for TypeScript.
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [tailwindcss(), reactRouter()],
resolve: { alias: { "~": fileURLToPath(new URL("./app", import.meta.url)) } },
});
app/global.d.ts#
Declare the Firebase environment variable for TypeScript. This declaration does not validate its JSON value.
interface ImportMetaEnv {
readonly VITE_FIREBASE_CONFIG: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Run the App#
npm run dev
Open the URL printed by Vite, normally http://localhost:5173. Sign in, add a task, toggle completion, and delete it. Use another signed-in tab to check realtime updates. Sign out and switch accounts to verify previous account tasks disappear. Visit /about to check the server-loaded document.
Check loading and error states too. Missing rules, an index, or a valid About document can cause Firebase requests to fail even when the application builds successfully.
Stop the development server with Ctrl+C when finished.
Check and Build#
npm run lint
npm run typecheck
npm run build
Lint uses Oxlint. Typecheck generates React Router route types before running TypeScript. Supply the Firebase configuration before building. The build emits browser assets in build/client and the server bundle in build/server.
After building, preview production locally:
npm start
This serves ./build/server/index.js with react-router-serve. Open the URL printed by the server. Stop it with Ctrl+C when finished.
Deploy both build outputs with the runtime dependencies. For this project's Vercel setup, select the React Router framework preset, use Node.js 24.x, and provide VITE_FIREBASE_CONFIG before building. Remove stale Remix build or output-directory overrides.
And that's it!
J
Comments
Share a thought or join the conversation.
Sign In to comment or reply.