Most teams wire up Firebase in a Next.js app, push to production, and only later notice that “API keys” appear in the browser. That looks scary. The key point: the Firebase Web API key is an identifier, not a credential, but you still need to configure it properly and enforce guardrails so nothing sensitive leaks. This guide walks you through a battle-tested setup for the App Router in Next.js, with clear boundaries between client and server, safe environment variable handling, and production checks. You will deploy cleanly on DigitalOcean App Platform, so make sure you have a DigitalOcean account ready before you begin.
What is actually secret in a Firebase + Next.js app
Treat the Firebase Web SDK config (the object with apiKey
, authDomain
, and similar fields) as public. It must be bundled into client code for the SDK to talk to Firebase. The real secrets are anything that grants privileged access, especially service account credentials for the Firebase Admin SDK. You will keep public config on the client, and keep service account credentials on the server. You will also restrict the public API key and write correct Security Rules, so the key alone cannot be abused.
Environment variables: how Next.js exposes values
Next.js bundles into the browser only variables that start with NEXT_PUBLIC_
. Everything else remains server-only. In other words, your Web SDK config goes in NEXT_PUBLIC_*
vars, while Admin SDK credentials live in unprefixed server vars. Build-time replacement means NEXT_PUBLIC_*
values are in the final JS, so assume they are public by design.
Folder structure you will use
lib/firebase/client.ts
for the Firebase client SDK singletonlib/firebase/admin.ts
for the Firebase Admin SDK singleton (server only).env.local
for local development variables- DigitalOcean App Platform environment variables for production
This keeps initialization logic centralized and prevents re-initialization during hot reloads.
Step 1: add client-side Firebase config safely
Create .env.local
and add only the Web SDK fields with the NEXT_PUBLIC_
prefix. Do not commit this file.
# .env.local
NEXT_PUBLIC_FIREBASE_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=...
NEXT_PUBLIC_FIREBASE_APP_ID=...
Why this is safe: these identifiers enable the client to call Firebase, but they do not grant admin access. You will still lock them down with key restrictions and Security Rules later.
Now initialize the client SDK as a singleton to avoid multiple app instances across Fast Refresh and RSC boundaries.
// lib/firebase/client.ts
import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getStorage } from "firebase/storage";
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!,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!,
};
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);
Import these exports only from Client Components or within client-side hooks. The values come from NEXT_PUBLIC_*
and are expected to be visible to the browser.
Step 2: keep the Admin SDK server-only
The Admin SDK uses a service account and must never run in the browser. You will inject the service account JSON via an environment variable in production and read it only on the server.
// lib/firebase/admin.ts
import { cert, getApps, initializeApp, getApp, App } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { getFirestore } from "firebase-admin/firestore";
// Ensure this file is only imported in server code.
let adminApp: App;
if (!getApps().length) {
const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY_JSON!);
adminApp = initializeApp({ credential: cert(serviceAccount) });
} else {
adminApp = getApp();
}
export const adminAuth = getAuth(adminApp);
export const adminDb = getFirestore(adminApp);
Use this from Server Components, route handlers, or Server Actions only. Never import it from Client Components. Store the JSON string as a secret in DigitalOcean (next step).
Step 3: add environment variables in DigitalOcean App Platform
In the App Platform dashboard, open your app, then Settings → App-Level Environment Variables → Edit. Add the NEXT_PUBLIC_*
variables and the server-only FIREBASE_SERVICE_ACCOUNT_KEY_JSON
. Mark the service account variable as a secret. Redeploy after saving.
Step 4: restrict the public API key
Even though the Firebase Web API key is not a secret, you should restrict where it can be used:
- In Google Cloud Console → API Keys, open your Firebase web key.
- Add HTTP referrer restrictions for your production domains and your preview domains.
- Restrict usage to the specific APIs your app needs.
This reduces abuse if someone copies your config. Note that API-key restrictions work at Google API level; your Security Rules still govern access to Firestore, Storage, and so on.
Step 5: write enforceable Firebase Security Rules
Rules decide who can read or write data. Start with least-privilege patterns, then widen. For example, a “users can read public profiles, but only update their own” pattern is common. Test rules before deployment and include emulator tests in CI.
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Publicly readable user profiles
match /profiles/{uid} {
allow read: if true;
allow write: if request.auth != null && request.auth.uid == uid;
}
// Private user data
match /users/{uid}/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
}
}
Step 6: use the right runtime boundary in Next.js
Follow this rule: client-side features use the client SDK, while privileged actions run server-side with the Admin SDK. Examples:
- Rendering user-specific UI: use
auth
fromlib/firebase/client.ts
. - Issuing custom tokens, verifying ID tokens in middleware, or performing privileged Firestore writes: use
adminAuth
oradminDb
fromlib/firebase/admin.ts
in a Route Handler or Server Action.
This keeps secrets out of the browser and aligns with Next.js environment variable behavior.
Step 7: verify nothing sensitive leaks
Run these checks before shipping:
- Client bundle audit. Search your production JS for
FIREBASE_SERVICE_ACCOUNT_KEY_JSON
or other server-only var names. You should find none. - Network audit. In DevTools, confirm browser calls go only to Firebase endpoints appropriate for the client SDK.
- Rules simulation. Use the Firebase Emulator to confirm reads/writes fail when unauthenticated or unauthorized.
- Key restriction test. Try calling an API from an unapproved origin to ensure restrictions block it.
Example: server-validated write with a Route Handler
This route uses the Admin SDK to write a value only after verifying the caller’s ID token server-side.
// app/api/secure-post/route.ts
import { NextResponse } from "next/server";
import { adminAuth, adminDb } from "@/lib/firebase/admin";
export async function POST(request: Request) {
const { idToken, data } = await request.json();
try {
const decoded = await adminAuth.verifyIdToken(idToken);
await adminDb.collection("secureData").doc(decoded.uid).set({ data, ts: Date.now() });
return NextResponse.json({ ok: true });
} catch (e) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
}
Pair this with client code that retrieves an ID token via getIdToken(auth.currentUser)
before calling the API. The client never sees service account credentials.
Common mistakes you will avoid
Developers often put Admin SDK code in shared modules imported by both server and client, or export all env vars from a single config file. Keep client and admin initializers completely separate, and never import lib/firebase/admin.ts
in client code. Another pitfall is adding NEXT_PUBLIC_
to a value that should remain server-only. If you need it on the server, drop the prefix. Finally, do not rely on API-key restrictions in place of Security Rules. Rules remain your authoritative barrier.
Deployment notes for DigitalOcean App Platform
App Platform supports both public environment variables and Secrets. Store the service account JSON as a secret environment variable and keep NEXT_PUBLIC_*
variables as standard env vars. When you redeploy, App Platform injects these into process.env
at runtime. If you need different values per environment, use app-level variables and per-component overrides.
Quick recap
You exposed only NEXT_PUBLIC_*
Web SDK config to the client, initialized the Firebase client as a singleton, kept Admin SDK credentials server-only via a DigitalOcean secret, restricted your API key, and enforced Security Rules. This setup prevents credential leakage and gives you a clean boundary for building features safely. If you add services later, revisit rules and key restrictions first.
Appendix: local development tips
- Keep
.env.local
out of version control. Create separate.env.production
values in your deployment environment instead of committing files. - If your service account JSON includes newlines in
private_key
, store it as raw JSON in DigitalOcean and parse it as shown earlier, rather than trying to escape newlines. - Prefer the Firebase Emulator for fast iteration and rule testing before deploying.