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

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
| Feature | Technology |
|---|---|
| Authentication | Firebase Auth (Google) |
| Database | Cloud Firestore |
| Real-time | Firestore onSnapshot |
| Frontend | Next.js 15 App Router |
| Styling | Tailwind CSS v4 |
| Deployment | Vercel |
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-appSelect 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
- Go to Firebase Console
- Click Add project
- Name your project (e.g.,
firebase-notes-app) - Disable Google Analytics if you don't need it
- Wait for the project to be created
Enable Authentication
- In the sidebar, go to Build > Authentication
- Click Get started
- In the Sign-in providers tab, enable Google
- Configure the support email and click Save
Create the Firestore Database
- Go to Build > Firestore Database
- Click Create database
- Select production mode
- Choose the closest region (e.g.,
us-east1oreurope-west1)
Get Configuration Keys
- Go to Project Settings > General
- In the Your apps section, click the Web icon (</>)
- 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:
- Go to Project Settings > Service accounts
- Click Generate a new private key
- Copy the
client_emailandprivate_keyvalues 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:indexesStep 17: Deploy to Vercel
Prepare for Deployment
- Push your code to GitHub/GitLab
- Connect your repository to Vercel
- 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_KEYConfigure Authorized Domain
In the Firebase console, add your Vercel domain to authorized domains:
- Go to Authentication > Settings > Authorized domains
- Add
your-app.vercel.app - 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 mainFirebase Cost Estimation
Firebase offers a generous free tier (Spark plan):
| Resource | Free (Spark) | Paid (Blaze) |
|---|---|---|
| Auth | 50,000 users/month | $0.0055/user |
| Firestore reads | 50,000/day | $0.06/100,000 |
| Firestore writes | 20,000/day | $0.18/100,000 |
| Firestore storage | 1 GB | $0.18/GB |
| Bandwidth | 10 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:
- Firestore rules are properly deployed
- The user is authenticated before accessing data
- The
userIdin the document matches the Firebase UID
Data Not Syncing in Real Time
Check that:
- You are using
onSnapshot, notgetDocs - The listener is not being unsubscribed prematurely (check your
useEffectcleanup) - Firestore rules allow reading
Error "FIREBASE_ADMIN_PRIVATE_KEY" in Production
The private key contains special characters. Make sure to:
- Wrap the value in double quotes in
.env.local - 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:
- Authentication with Firebase Auth and Google Sign-In
- Data storage with Cloud Firestore
- Real-time synchronization with
onSnapshot - Server Actions for secure write operations
- Security rules to protect your data
- Pagination for handling large data volumes
- 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.
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

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Real-Time App with Supabase and Next.js 15: Complete Guide
Learn how to build a full-stack real-time application using Supabase and Next.js 15 App Router. This guide covers authentication, database setup, Row Level Security, and real-time subscriptions.

Better Auth with Next.js 15: The Complete Authentication Guide for 2026
Learn how to implement full-featured authentication in Next.js 15 using Better Auth. This tutorial covers email/password, OAuth, sessions, middleware protection, and role-based access control.