If you have ever rebuilt the same SaaS foundation and lost days to webhook retries, auth race conditions, or a Content-Security-Policy that silently breaks Google sign-in and Stripe.js, you are not alone. The gap between a demo and a production-ready starter is almost never the feature work — it is these eight boring, load-bearing decisions. Get them right once and every feature you build after sits on solid ground. Get them wrong and you find out in production, after money has moved.
Who this is for: developers building a Next.js + Firebase + Stripe SaaS who want a production baseline instead of another half-working boilerplate.
Step 1: Start with server-verified auth, not client-only auth
The Firebase browser SDK gives you a user object in React. That is not the same thing as your server knowing who is calling. Server Components, API routes, and admin checks cannot read browser state, so if the client is your only source of identity you end up passing uids around in request bodies — which is to say, letting the browser claim who it is.
The fix is a server-side session cookie. Exchange the Firebase ID token for an httpOnly cookie once, then every server read verifies that cookie:
// POST /api/auth/session — mint the cookie from a verified ID token
const decoded = await adminAuth().verifyIdToken(idToken);
const sessionCookie = await adminAuth().createSessionCookie(idToken, {
expiresIn: SESSION_TTL_MS,
});
cookieStore.set(SESSION_COOKIE, sessionCookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});Now a Server Component can call requireUser() and get a verified uid with no client round trip and no flash of the wrong state. Client-side route guards still have a job — they are good UX — but they are not enforcement. Every protected read and every write must re-check server-side, because a client gate is a suggestion the browser is free to ignore.
Keep secure conditional on production, or the cookie is never set over plain http and local development mysteriously never logs anyone in.
Step 2: Make the auth flow resilient to slow profile reads
Auth is two problems wearing one coat: identity (who is this?) and profile readiness (do I have their data yet?). Most blank-screen bugs come from conflating them. The user is authenticated in 200ms, but the app renders a spinner until a Firestore profile document resolves — and if that read fails, or the document does not exist yet because it is created by the same request that is being awaited, the spinner is forever.
Two rules. First, gate rendering on identity, not on profile: render the shell as soon as you know who the user is, and let profile-dependent widgets show their own loading state. Second, every await that blocks first paint needs a timeout and a fallback — an app that renders with partial data beats an app that hangs.
A related trap in React 19: do not copy external state into component state inside an effect. It is a race by construction, and react-hooks/set-state-in-effect now flags it as an error. Read external state with useSyncExternalStore, or move the update into the callback that actually observes the change.
Step 3: Handle Stripe webhooks as if they will retry — because they will
Stripe retries any non-2xx response, and can deliver the same event twice even after a success. If your handler is not idempotent, a retry double-grants access, double-increments a counter, or sends a second confirmation email. Assume every handler runs more than once.
Idempotency means recording the event id before doing the work, and skipping anything you have already seen:
if (await isEventProcessed(event.id)) {
return new Response("ok", { status: 200 }); // already handled
}
await applyPurchase(event); // atomic, in a transaction
await markEventProcessed(event.id);Three details that bite. The signature check needs the raw request body — read await req.text(), never a parsed JSON object, because Stripe signs the exact bytes it sent. The route must run on the Node.js runtime for the same reason. And you should return 500 only for retryable failures: an unmapped price id or a missing user should return 200 with a log line, since retrying a mapping mistake forever just fills your logs while Stripe eventually gives up.
One Firestore-specific gotcha, because it produces a spectacular failure mode: a transaction must do all its reads before any write. Collapse an idempotency check and its write into one read-then-write helper and the transaction throws at runtime — every purchase 500s while Stripe shows the payment as successful. That is why the check above is two functions and not one.
Step 4: Store subscription state in your database, not in your UI
The success page is not evidence of payment. The user can close the tab before the redirect, the redirect can fail, and anyone can navigate directly to /success. Any code path that grants access from the browser is a code path that grants access to people who did not pay.
The webhook is the only signal that is signed, retried, and independent of the browser — so it is the only writer of billing state. The frontend reads a durable record; it never decides one. Concretely: exactly one module writes entitlements, and it is reachable only from the signature-verified webhook.
A stronger version of the same idea is to keep a ledger rather than a flag. Store one row per payment, and derive access as a function of the paid rows. Recomputing beats toggling because refunds, partial refunds, disputes, and out-of-order event delivery all converge to the right answer instead of leaving a boolean stuck in the wrong position.
Step 5: Lock down uploads and storage from the start
Proxying file uploads through your own server burns function time and memory for no benefit. Issue a presigned URL and let the browser PUT straight to object storage — S3, R2, whatever you use.
The security rule is short: the key comes from the verified session, never from the client. Scope every upload under users/{uid}/…using the uid your server derived from the session cookie. If the client sends the path, the client can send someone else's path.
For paid assets, never put the bucket behind a permanent public URL. Presign the download every time, gated on the entitlement check, so the file is reachable for minutes rather than forever at a guessable address.
Step 6: Treat Content-Security-Policy as part of the product
CSP is where this stack most often breaks after deploy, because local development frequently runs without the header. Google sign-in opens a popup on a Google domain, Stripe.js loads a script and mounts an iframe, and Firebase talks to several different hosts. A policy that forgets any one of them produces a silent, console-only failure on your checkout page.
Directives this stack actually needs:
script-src—https://js.stripe.com,https://apis.google.com, andhttps://www.gstatic.comframe-src—https://js.stripe.com,https://hooks.stripe.com, and your Firebase auth domain (Stripe Elements and the sign-in handler are both iframes)connect-src—https://api.stripe.com,https://*.googleapis.com, andhttps://*.firebaseio.comfor realtimeimg-src—https://*.stripe.complusdata:for inlined assets
Also set frame-ancestors 'none' to block clickjacking, and note that X-Frame-Options does not replace it. Test sign-in and a real checkout on a preview deployment with the production headers applied — not on localhost — before you ship.
Step 7: Gate admin features on the server, in layers
Hiding an admin link is not security. Anyone can type the URL, and the data lives behind an API route that does not care what your navigation rendered. Authorization belongs on the server, checked independently at every entrance.
Four layers, each of which must hold on its own:
- An allowlist or role claim resolved server-side.
- The admin page itself, as a Server Component, refusing to render for a non-admin.
- Every admin API route calling its own
requireAdmin()— routes are independently reachable, so the page's check does not protect them. - Database rules denying direct client access to privileged collections.
Prefer notFound()over a 403 for the admin surface, and keep the 404 copy neutral. A page that says “forbidden” confirms that the page exists, which is information you did not need to give away.
Step 8: Start from a foundation that already bakes this in
None of the above is hard in isolation. It is hard because all eight have to be right at the same time before a single paid signup works end-to-end, and because each one fails quietly in a different place — the browser console, the Stripe dashboard, a Firestore rules log, or nowhere at all.
That is the case for starting from a codebase where they are already wired. Weekendstack ships this exact stack — Next.js App Router, Firebase Auth with server-verified session cookies, Firestore with locked-down rules, Stripe with an idempotent ledger-based webhook, R2 presigned uploads, transactional email, and a server-gated admin dashboard — from a single .env.local. It also comes with an AI prompt pack written against that same architecture, so the features you generate next match the foundation instead of fighting it.
The five mistakes that cause most of the pain
- Relying on client-only auth, then discovering Server Components cannot trust the user state.
- Processing Stripe webhooks without idempotency, double-granting access on a retry.
- Letting a single Firestore read gate the entire app render, producing the blank-screen auth hang.
- UI-only admin protection instead of server-side authorization.
- Leaving CSP until launch week, then breaking Stripe.js and Google sign-in in production only.
Want the foundation done and the edge cases already handled? See Weekendstack. Related reading: the SaaS security checklist, Supabase vs Firebase for a SaaS MVP, and whether Next.js is a good fit for SaaS.