Build a Full-Stack App with Firebase and Next.js 15: Auth, Firestore & Real-Time

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Firebase remains one of the most popular backend platforms for web developers in 2026. Combined with Next.js 15 and its App Router, it enables you to create performant applications with authentication, a real-time database, and server-side rendering — all without managing a backend server.

In this tutorial, we will build a collaborative notes application featuring:

  • Google authentication via Firebase Auth
  • Data storage in Firestore
  • Real-time synchronization between users
  • Next.js 15 Server Actions for secure operations
  • Deployment to Vercel

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • A Google account for Firebase
  • Basic knowledge of React and TypeScript
  • A code editor (VS Code recommended)
  • npm or pnpm as your package manager

What You'll Build

A collaborative notes application where users can:

  • Sign in with their Google account
  • Create, edit, and delete notes
  • See other users' changes in real time
  • Organize notes by categories
FeatureTechnology
AuthenticationFirebase Auth (Google)
DatabaseCloud Firestore
Real-timeFirestore onSnapshot
FrontendNext.js 15 App Router
StylingTailwind CSS v4
DeploymentVercel

Step 1: Create the Next.js Project

Start by initializing a new Next.js 15 project with TypeScript and Tailwind CSS:

npx create-next-app@latest firebase-notes-app --typescript --tailwind --app --src-dir --eslint
cd firebase-notes-app

Select the default options when prompted. Then install Firebase dependencies:

npm install firebase firebase-admin
  • firebase: Client SDK for the browser (Auth, Firestore)
  • firebase-admin: Server SDK for Server Actions and middleware

Step 2: Set Up the Firebase Project

Create a Firebase Project

  1. Go to Firebase Console
  2. Click Add project
  3. Name your project (e.g., firebase-notes-app)
  4. Disable Google Analytics if you don't need it
  5. Wait for the project to be created

Enable Authentication

  1. In the sidebar, go to Build > Authentication
  2. Click Get started
  3. In the Sign-in providers tab, enable Google
  4. Configure the support email and click Save

Create the Firestore Database

  1. Go to Build > Firestore Database
  2. Click Create database
  3. Select production mode
  4. Choose the closest region (e.g., us-east1 or europe-west1)

Get Configuration Keys

  1. Go to Project Settings > General
  2. In the Your apps section, click the Web icon (</>)
  3. Register your app and copy the configuration

Step 3: Configure Environment Variables

Create a .env.local file at the project root:

# Firebase Client SDK
NEXT_PUBLIC_FIREBASE_API_KEY=your-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef
 
# Firebase Admin SDK
FIREBASE_ADMIN_PROJECT_ID=your-project-id
FIREBASE_ADMIN_CLIENT_EMAIL=firebase-adminsdk-xxx@your-project.iam.gserviceaccount.com
FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nyour-private-key\n-----END PRIVATE KEY-----\n"

Never commit your Firebase Admin keys to your Git repository. The .env.local file is already in .gitignore by default with Next.js.

To get the Admin private key:

  1. Go to Project Settings > Service accounts
  2. Click Generate a new private key
  3. Copy the client_email and private_key values from the downloaded JSON file

Step 4: Initialize Firebase Client-Side

Create the Firebase configuration file for the browser:

// src/lib/firebase.ts
import { initializeApp, getApps } from "firebase/app";
import { getAuth, GoogleAuthProvider } 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,
};
 
// Prevent double initialization in development (hot reload)
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
 
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
export const db = getFirestore(app);

The getApps().length === 0 check is essential with Next.js because hot reload in development can attempt to re-initialize Firebase multiple times.

Step 5: Configure Firebase Admin Server-Side

Create the configuration for server operations:

// src/lib/firebase-admin.ts
import { initializeApp, getApps, cert } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { getFirestore } from "firebase-admin/firestore";
 
const adminConfig = {
  credential: cert({
    projectId: process.env.FIREBASE_ADMIN_PROJECT_ID,
    clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, "\n"),
  }),
};
 
const adminApp =
  getApps().length === 0 ? initializeApp(adminConfig) : getApps()[0];
 
export const adminAuth = getAuth(adminApp);
export const adminDb = getFirestore(adminApp);

The replace(/\\n/g, "\n") is necessary because environment variables store newlines as literal \n strings.

Step 6: Create the Authentication Context

Implement a React provider that manages authentication state throughout the application:

// src/contexts/AuthContext.tsx
"use client";
 
import {
  createContext,
  useContext,
  useEffect,
  useState,
  type ReactNode,
} from "react";
import {
  onAuthStateChanged,
  signInWithPopup,
  signOut as firebaseSignOut,
  type User,
} from "firebase/auth";
import { auth, googleProvider } from "@/lib/firebase";
 
interface AuthContextType {
  user: User | null;
  loading: boolean;
  signInWithGoogle: () => Promise<void>;
  signOut: () => Promise<void>;
}
 
const AuthContext = createContext<AuthContextType | null>(null);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      setUser(user);
      setLoading(false);
    });
 
    return () => unsubscribe();
  }, []);
 
  const signInWithGoogle = async () => {
    try {
      await signInWithPopup(auth, googleProvider);
    } catch (error) {
      console.error("Sign-in error:", error);
      throw error;
    }
  };
 
  const signOut = async () => {
    try {
      await firebaseSignOut(auth);
    } catch (error) {
      console.error("Sign-out error:", error);
      throw error;
    }
  };
 
  return (
    <AuthContext.Provider value={{ user, loading, signInWithGoogle, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

Wrap your root layout with the provider:

// src/app/layout.tsx
import { AuthProvider } from "@/contexts/AuthContext";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}

Step 7: Build the Login Component

Create an elegant login component:

// src/components/LoginButton.tsx
"use client";
 
import { useAuth } from "@/contexts/AuthContext";
 
export function LoginButton() {
  const { user, loading, signInWithGoogle, signOut } = useAuth();
 
  if (loading) {
    return (
      <div className="animate-pulse h-10 w-32 bg-gray-200 rounded-lg" />
    );
  }
 
  if (user) {
    return (
      <div className="flex items-center gap-3">
        <img
          src={user.photoURL || "/default-avatar.png"}
          alt={user.displayName || "Avatar"}
          className="w-8 h-8 rounded-full"
        />
        <span className="text-sm font-medium">{user.displayName}</span>
        <button
          onClick={signOut}
          className="px-4 py-2 text-sm bg-red-500 text-white rounded-lg
                     hover:bg-red-600 transition-colors"
        >
          Sign Out
        </button>
      </div>
    );
  }
 
  return (
    <button
      onClick={signInWithGoogle}
      className="flex items-center gap-2 px-6 py-3 bg-white border
                 border-gray-300 rounded-lg shadow-sm hover:shadow-md
                 transition-all duration-200"
    >
      <svg className="w-5 h-5" viewBox="0 0 24 24">
        <path
          fill="#4285F4"
          d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06
             5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
        />
        <path
          fill="#34A853"
          d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23
             1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99
             20.53 7.7 23 12 23z"
        />
        <path
          fill="#FBBC05"
          d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43
             8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
        />
        <path
          fill="#EA4335"
          d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45
             2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66
             2.84c.87-2.6 3.3-4.53 6.16-4.53z"
        />
      </svg>
      Sign in with Google
    </button>
  );
}

Step 8: Define Types and Firestore Structure

Define TypeScript types for your data:

// src/types/note.ts
export interface Note {
  id: string;
  title: string;
  content: string;
  category: string;
  userId: string;
  userDisplayName: string;
  userPhotoURL: string;
  createdAt: Date;
  updatedAt: Date;
}
 
export interface NoteInput {
  title: string;
  content: string;
  category: string;
}
 
export const CATEGORIES = [
  "Personal",
  "Work",
  "Ideas",
  "Tasks",
  "Other",
] as const;

The Firestore structure will be:

notes (collection)
  └── {noteId} (document)
        ├── title: string
        ├── content: string
        ├── category: string
        ├── userId: string
        ├── userDisplayName: string
        ├── userPhotoURL: string
        ├── createdAt: Timestamp
        └── updatedAt: Timestamp

Step 9: Configure Firestore Security Rules

Before writing code, configure security rules in the Firebase console:

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    // Notes are readable by all authenticated users
    // but only modifiable by their author
    match /notes/{noteId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null
                    && request.resource.data.userId == request.auth.uid;
      allow update, delete: if request.auth != null
                            && resource.data.userId == request.auth.uid;
    }
  }
}

Never leave rules in test mode (allow read, write: if true) in production. This exposes all your data to anyone.

These rules ensure that:

  • Only authenticated users can read notes
  • A user can only create notes with their own userId
  • Only the author can update or delete their notes

Step 10: Create Server Actions for CRUD Operations

Use Next.js 15 Server Actions for secure write operations:

// src/app/actions/notes.ts
"use server";
 
import { adminDb } from "@/lib/firebase-admin";
import { FieldValue } from "firebase-admin/firestore";
 
export async function createNote(data: {
  title: string;
  content: string;
  category: string;
  userId: string;
  userDisplayName: string;
  userPhotoURL: string;
}) {
  try {
    const docRef = await adminDb.collection("notes").add({
      ...data,
      createdAt: FieldValue.serverTimestamp(),
      updatedAt: FieldValue.serverTimestamp(),
    });
 
    return { success: true, id: docRef.id };
  } catch (error) {
    console.error("Error creating note:", error);
    return { success: false, error: "Unable to create note" };
  }
}
 
export async function updateNote(
  noteId: string,
  userId: string,
  data: { title?: string; content?: string; category?: string }
) {
  try {
    const noteRef = adminDb.collection("notes").doc(noteId);
    const noteDoc = await noteRef.get();
 
    if (!noteDoc.exists) {
      return { success: false, error: "Note not found" };
    }
 
    if (noteDoc.data()?.userId !== userId) {
      return { success: false, error: "Unauthorized" };
    }
 
    await noteRef.update({
      ...data,
      updatedAt: FieldValue.serverTimestamp(),
    });
 
    return { success: true };
  } catch (error) {
    console.error("Error updating note:", error);
    return { success: false, error: "Unable to update note" };
  }
}
 
export async function deleteNote(noteId: string, userId: string) {
  try {
    const noteRef = adminDb.collection("notes").doc(noteId);
    const noteDoc = await noteRef.get();
 
    if (!noteDoc.exists) {
      return { success: false, error: "Note not found" };
    }
 
    if (noteDoc.data()?.userId !== userId) {
      return { success: false, error: "Unauthorized" };
    }
 
    await noteRef.delete();
    return { success: true };
  } catch (error) {
    console.error("Error deleting note:", error);
    return { success: false, error: "Unable to delete note" };
  }
}

Server Actions always verify the userId before any modification. This double verification (Firestore rules + Server Actions) provides defense in depth.

Step 11: Implement Real-Time Listening

Create a custom hook that listens for Firestore changes in real time:

// src/hooks/useNotes.ts
"use client";
 
import { useEffect, useState } from "react";
import {
  collection,
  query,
  orderBy,
  onSnapshot,
  type Timestamp,
} from "firebase/firestore";
import { db } from "@/lib/firebase";
import type { Note } from "@/types/note";
 
export function useNotes() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    const q = query(collection(db, "notes"), orderBy("updatedAt", "desc"));
 
    const unsubscribe = onSnapshot(
      q,
      (snapshot) => {
        const notesData = snapshot.docs.map((doc) => {
          const data = doc.data();
          return {
            id: doc.id,
            title: data.title,
            content: data.content,
            category: data.category,
            userId: data.userId,
            userDisplayName: data.userDisplayName,
            userPhotoURL: data.userPhotoURL,
            createdAt: (data.createdAt as Timestamp)?.toDate() || new Date(),
            updatedAt: (data.updatedAt as Timestamp)?.toDate() || new Date(),
          } satisfies Note;
        });
 
        setNotes(notesData);
        setLoading(false);
      },
      (err) => {
        console.error("Error listening to notes:", err);
        setError("Unable to load notes");
        setLoading(false);
      }
    );
 
    return () => unsubscribe();
  }, []);
 
  return { notes, loading, error };
}

Firestore's onSnapshot establishes a persistent WebSocket connection. Every time a document in the notes collection is added, modified, or deleted, the callback fires automatically — no polling or page reloading needed.

Step 12: Build the Note Creation Form

Create the form component with validation:

// src/components/NoteForm.tsx
"use client";
 
import { useState, useTransition } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { createNote } from "@/app/actions/notes";
import { CATEGORIES } from "@/types/note";
 
export function NoteForm({ onSuccess }: { onSuccess?: () => void }) {
  const { user } = useAuth();
  const [isPending, startTransition] = useTransition();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [category, setCategory] = useState(CATEGORIES[0]);
 
  if (!user) return null;
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
 
    startTransition(async () => {
      const result = await createNote({
        title,
        content,
        category,
        userId: user.uid,
        userDisplayName: user.displayName || "Anonymous",
        userPhotoURL: user.photoURL || "",
      });
 
      if (result.success) {
        setTitle("");
        setContent("");
        setCategory(CATEGORIES[0]);
        onSuccess?.();
      }
    });
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4 p-6 bg-white
                                             rounded-xl shadow-sm border">
      <h2 className="text-lg font-semibold">New Note</h2>
 
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Note title"
        required
        className="w-full px-4 py-2 border rounded-lg focus:ring-2
                   focus:ring-blue-500 focus:border-transparent"
      />
 
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Note content..."
        required
        rows={4}
        className="w-full px-4 py-2 border rounded-lg focus:ring-2
                   focus:ring-blue-500 focus:border-transparent resize-none"
      />
 
      <select
        value={category}
        onChange={(e) => setCategory(e.target.value)}
        className="w-full px-4 py-2 border rounded-lg focus:ring-2
                   focus:ring-blue-500"
      >
        {CATEGORIES.map((cat) => (
          <option key={cat} value={cat}>
            {cat}
          </option>
        ))}
      </select>
 
      <button
        type="submit"
        disabled={isPending}
        className="w-full py-3 bg-blue-600 text-white rounded-lg
                   font-medium hover:bg-blue-700 disabled:opacity-50
                   transition-colors"
      >
        {isPending ? "Creating..." : "Create Note"}
      </button>
    </form>
  );
}

Note the use of useTransition: this is the recommended pattern by React 19 and Next.js 15 for calling Server Actions from a form. It automatically manages the loading state without extra useState.

Step 13: Display Notes with Real-Time Updates

Create the component that displays notes and updates automatically:

// src/components/NotesList.tsx
"use client";
 
import { useState, useTransition } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useNotes } from "@/hooks/useNotes";
import { deleteNote } from "@/app/actions/notes";
import type { Note } from "@/types/note";
 
function NoteCard({ note }: { note: Note }) {
  const { user } = useAuth();
  const [isPending, startTransition] = useTransition();
  const isOwner = user?.uid === note.userId;
 
  const handleDelete = () => {
    if (!confirm("Delete this note?")) return;
 
    startTransition(async () => {
      await deleteNote(note.id, user!.uid);
    });
  };
 
  const timeAgo = getTimeAgo(note.updatedAt);
 
  return (
    <div className="p-5 bg-white rounded-xl shadow-sm border
                    hover:shadow-md transition-shadow">
      <div className="flex items-start justify-between">
        <div className="flex-1">
          <span className="inline-block px-2 py-1 text-xs font-medium
                          bg-blue-100 text-blue-700 rounded-full mb-2">
            {note.category}
          </span>
          <h3 className="text-lg font-semibold">{note.title}</h3>
          <p className="mt-2 text-gray-600 whitespace-pre-wrap">
            {note.content}
          </p>
        </div>
 
        {isOwner && (
          <button
            onClick={handleDelete}
            disabled={isPending}
            className="ml-3 p-2 text-red-500 hover:bg-red-50
                       rounded-lg transition-colors"
            aria-label="Delete"
          >
            {isPending ? "..." : "✕"}
          </button>
        )}
      </div>
 
      <div className="mt-4 flex items-center gap-2 text-sm text-gray-500">
        <img
          src={note.userPhotoURL || "/default-avatar.png"}
          alt={note.userDisplayName}
          className="w-5 h-5 rounded-full"
        />
        <span>{note.userDisplayName}</span>
        <span>·</span>
        <span>{timeAgo}</span>
      </div>
    </div>
  );
}
 
function getTimeAgo(date: Date): string {
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  if (seconds < 60) return "just now";
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  return `${days}d ago`;
}
 
export function NotesList() {
  const { notes, loading, error } = useNotes();
  const [filter, setFilter] = useState<string>("All");
 
  if (loading) {
    return (
      <div className="space-y-4">
        {[1, 2, 3].map((i) => (
          <div
            key={i}
            className="h-32 bg-gray-100 rounded-xl animate-pulse"
          />
        ))}
      </div>
    );
  }
 
  if (error) {
    return (
      <div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div>
    );
  }
 
  const filteredNotes =
    filter === "All"
      ? notes
      : notes.filter((n) => n.category === filter);
 
  return (
    <div>
      <div className="flex gap-2 mb-4 flex-wrap">
        {["All", "Personal", "Work", "Ideas", "Tasks", "Other"].map(
          (cat) => (
            <button
              key={cat}
              onClick={() => setFilter(cat)}
              className={`px-3 py-1 rounded-full text-sm transition-colors ${
                filter === cat
                  ? "bg-blue-600 text-white"
                  : "bg-gray-100 text-gray-700 hover:bg-gray-200"
              }`}
            >
              {cat}
            </button>
          )
        )}
      </div>
 
      {filteredNotes.length === 0 ? (
        <p className="text-center text-gray-500 py-8">
          No notes yet. Create your first note!
        </p>
      ) : (
        <div className="space-y-4">
          {filteredNotes.map((note) => (
            <NoteCard key={note.id} note={note} />
          ))}
        </div>
      )}
    </div>
  );
}

Thanks to the useNotes hook that uses onSnapshot, the list updates instantly when another user adds or deletes a note — no reload needed.

Step 14: Assemble the Main Page

Combine all components on the home page:

// src/app/page.tsx
"use client";
 
import { useAuth } from "@/contexts/AuthContext";
import { LoginButton } from "@/components/LoginButton";
import { NoteForm } from "@/components/NoteForm";
import { NotesList } from "@/components/NotesList";
 
export default function HomePage() {
  const { user, loading } = useAuth();
 
  return (
    <main className="min-h-screen bg-gray-50">
      <header className="bg-white border-b px-6 py-4">
        <div className="max-w-4xl mx-auto flex items-center justify-between">
          <h1 className="text-2xl font-bold">
            📝 Collaborative Notes
          </h1>
          <LoginButton />
        </div>
      </header>
 
      <div className="max-w-4xl mx-auto px-6 py-8">
        {loading ? (
          <div className="flex justify-center py-12">
            <div className="animate-spin h-8 w-8 border-4 border-blue-600
                          border-t-transparent rounded-full" />
          </div>
        ) : !user ? (
          <div className="text-center py-16">
            <h2 className="text-3xl font-bold mb-4">
              Welcome to Collaborative Notes
            </h2>
            <p className="text-gray-600 mb-8">
              Sign in to create and share notes in real time
            </p>
            <LoginButton />
          </div>
        ) : (
          <div className="grid gap-8 md:grid-cols-[350px_1fr]">
            <aside>
              <NoteForm />
            </aside>
            <section>
              <h2 className="text-xl font-semibold mb-4">
                All Notes
              </h2>
              <NotesList />
            </section>
          </div>
        )}
      </div>
    </main>
  );
}

Step 15: Add Authentication Middleware (Optional)

To protect certain routes server-side, add middleware:

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  // Check for Firebase session cookie
  const session = request.cookies.get("__session");
 
  // Protected routes
  const protectedPaths = ["/dashboard", "/settings"];
  const isProtected = protectedPaths.some((path) =>
    request.nextUrl.pathname.startsWith(path)
  );
 
  if (isProtected && !session) {
    return NextResponse.redirect(new URL("/", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

In our notes app, authentication is handled client-side via the React context. Middleware is useful if you add protected pages rendered server-side.

Step 16: Optimize Performance

Pagination with Firestore

For apps with many notes, implement pagination:

// src/hooks/useNotesPaginated.ts
"use client";
 
import { useState, useEffect, useCallback } from "react";
import {
  collection,
  query,
  orderBy,
  limit,
  startAfter,
  onSnapshot,
  type QueryDocumentSnapshot,
  type DocumentData,
} from "firebase/firestore";
import { db } from "@/lib/firebase";
import type { Note } from "@/types/note";
 
const PAGE_SIZE = 10;
 
export function useNotesPaginated() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [lastDoc, setLastDoc] =
    useState<QueryDocumentSnapshot<DocumentData> | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(true);
 
  // Load first page
  useEffect(() => {
    const q = query(
      collection(db, "notes"),
      orderBy("updatedAt", "desc"),
      limit(PAGE_SIZE)
    );
 
    const unsubscribe = onSnapshot(q, (snapshot) => {
      const data = snapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
        createdAt: doc.data().createdAt?.toDate() || new Date(),
        updatedAt: doc.data().updatedAt?.toDate() || new Date(),
      })) as Note[];
 
      setNotes(data);
      setLastDoc(snapshot.docs[snapshot.docs.length - 1] || null);
      setHasMore(snapshot.docs.length === PAGE_SIZE);
      setLoading(false);
    });
 
    return () => unsubscribe();
  }, []);
 
  const loadMore = useCallback(() => {
    if (!lastDoc || !hasMore) return;
 
    const q = query(
      collection(db, "notes"),
      orderBy("updatedAt", "desc"),
      startAfter(lastDoc),
      limit(PAGE_SIZE)
    );
 
    onSnapshot(q, (snapshot) => {
      const newNotes = snapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
        createdAt: doc.data().createdAt?.toDate() || new Date(),
        updatedAt: doc.data().updatedAt?.toDate() || new Date(),
      })) as Note[];
 
      setNotes((prev) => [...prev, ...newNotes]);
      setLastDoc(snapshot.docs[snapshot.docs.length - 1] || null);
      setHasMore(snapshot.docs.length === PAGE_SIZE);
    });
  }, [lastDoc, hasMore]);
 
  return { notes, loading, hasMore, loadMore };
}

Firestore Indexes

Create a firestore.indexes.json file to optimize queries:

{
  "indexes": [
    {
      "collectionGroup": "notes",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "category", "order": "ASCENDING" },
        { "fieldPath": "updatedAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "notes",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "updatedAt", "order": "DESCENDING" }
      ]
    }
  ]
}

Deploy indexes with:

npx firebase deploy --only firestore:indexes

Step 17: Deploy to Vercel

Prepare for Deployment

  1. Push your code to GitHub/GitLab
  2. Connect your repository to Vercel
  3. Add environment variables in Vercel project settings
# Variables to configure in Vercel
NEXT_PUBLIC_FIREBASE_API_KEY
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
NEXT_PUBLIC_FIREBASE_PROJECT_ID
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
NEXT_PUBLIC_FIREBASE_APP_ID
FIREBASE_ADMIN_PROJECT_ID
FIREBASE_ADMIN_CLIENT_EMAIL
FIREBASE_ADMIN_PRIVATE_KEY

Configure Authorized Domain

In the Firebase console, add your Vercel domain to authorized domains:

  1. Go to Authentication > Settings > Authorized domains
  2. Add your-app.vercel.app
  3. Add your custom domain if you have one

Deploy

# With Vercel CLI
npm i -g vercel
vercel --prod
 
# Or simply push to main branch for automatic deployment
git push origin main

Firebase Cost Estimation

Firebase offers a generous free tier (Spark plan):

ResourceFree (Spark)Paid (Blaze)
Auth50,000 users/month$0.0055/user
Firestore reads50,000/day$0.06/100,000
Firestore writes20,000/day$0.18/100,000
Firestore storage1 GB$0.18/GB
Bandwidth10 GB/month$0.12/GB

For a notes app with a few hundred users, the free plan will be more than sufficient.

Troubleshooting

Error "auth/popup-blocked"

Some browsers block popups. Solution:

// Alternative: use signInWithRedirect instead of signInWithPopup
import { signInWithRedirect } from "firebase/auth";
 
const signInWithGoogle = async () => {
  await signInWithRedirect(auth, googleProvider);
};

Error "Missing or insufficient permissions"

Verify that:

  1. Firestore rules are properly deployed
  2. The user is authenticated before accessing data
  3. The userId in the document matches the Firebase UID

Data Not Syncing in Real Time

Check that:

  • You are using onSnapshot, not getDocs
  • The listener is not being unsubscribed prematurely (check your useEffect cleanup)
  • Firestore rules allow reading

Error "FIREBASE_ADMIN_PRIVATE_KEY" in Production

The private key contains special characters. Make sure to:

  1. Wrap the value in double quotes in .env.local
  2. Apply the replace(/\\n/g, "\n") in the Admin configuration

Going Further

Here are some ideas to extend this project:

  • Full-text search: Integrate Algolia or Typesense for searching notes
  • Note sharing: Add granular per-note permissions
  • Offline mode: Enable Firestore persistence for offline functionality
  • Push notifications: Use Firebase Cloud Messaging to notify about updates
  • PDF export: Allow exporting notes as PDF
  • Rich editor: Replace the textarea with TipTap or Lexical

Conclusion

In this tutorial, you learned how to build a complete full-stack application with Firebase and Next.js 15. We covered:

  1. Authentication with Firebase Auth and Google Sign-In
  2. Data storage with Cloud Firestore
  3. Real-time synchronization with onSnapshot
  4. Server Actions for secure write operations
  5. Security rules to protect your data
  6. Pagination for handling large data volumes
  7. Deployment to Vercel

Firebase combined with Next.js 15 offers a powerful stack for creating interactive, collaborative applications — without needing to configure a backend server, database, or authentication system manually. It's an excellent choice for MVPs, personal projects, and small to medium-scale applications.


Want to read more tutorials? Check out our latest tutorial on Receive GitLab Comments on WhatsApp Using Webhooks.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles