Most Next.js + Supabase tutorials stop at "it works on localhost." That's exactly where the hard part begins.
I build SaaS apps with this stack, and almost every painful incident I've debugged in production had the same quality: nothing throws, nothing crashes, the build is green, and yet the app is quietly wrong. Empty lists where there should be data. Users logged out after a refresh. A customer charged twice. A dashboard that won't update no matter how many times you reload.
Here are four of those failure modes, what actually causes them, and the fix. Code is illustrative — adapt it to your project structure.
1. Supabase returns an empty array for rows that clearly exist
You enable Row Level Security on a table, deploy, and suddenly a query that worked in development returns []. No error. No exception. Just an empty array.
Root cause: RLS is deny-by-default. The moment you run alter table ... enable row level security, every row is hidden unless a policy explicitly allows it. If you have no matching SELECT policy for the role making the request (usually authenticated, sometimes anon), Postgres doesn't error — it simply returns zero rows. The data is there; you're just not allowed to see it.
This is the single most common "it worked locally" bug, because locally people often query with the service role key (which bypasses RLS), then ship with the anon key (which doesn't).
The fix: add a scoped SELECT policy.
alter table public.projects enable row level security;
create policy "Users can read their own projects"
on public.projects
for select
to authenticated
using (owner_id = auth.uid());
Then test the query with an actual authenticated client, not the service role. If you also write rows, remember INSERT policies use with check, not using:
create policy "Users can insert their own projects"
on public.projects
for insert
to authenticated
with check (owner_id = auth.uid());
Rule of thumb: empty array + no error = missing or mismatched policy for the role you're querying as.
2. The session disappears after a page refresh (SSR)
Auth works, the user logs in, everything's fine — until they hard-refresh and they're logged out on the server. Client-side state looked authenticated; the server render disagreed.
Root cause: two things, usually together.
First, on the server you should resolve the user with supabase.auth.getUser(), not getSession(). getSession() just reads the cookie and trusts it; getUser() revalidates the token against the Supabase auth server, which is what you want for anything that gates access on the server.
Second, the cookie refresh path has to be wired through middleware. The session token gets rotated, and if you don't write the refreshed cookies back onto the response, the server keeps seeing a stale (or absent) session. The order matters: create the client, immediately call getUser(), and return a response that carries the updated cookies.
The fix (middleware, App Router + @supabase/ssr):
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
// Call getUser() right away — don't run other logic between
// creating the client and this call, or the cookie refresh can be lost.
const {
data: { user },
} = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
export const config = {
matcher: ["/dashboard/:path*"],
};
Two related traps from the same area: if your matcher accidentally covers /auth/callback or /login, you get a redirect loop; if it covers _next/static, you break asset loading. Keep the matcher narrow.
3. Stripe webhooks fire twice and you provision (or charge) twice
Stripe retries webhook deliveries. It will also occasionally send the same event more than once. If your handler does work on every delivery, retries turn into duplicate subscription rows, double entitlements, or double side effects.
Root cause, part one — signature verification: webhook signature verification needs the raw request body. In an App Router route handler that means await req.text(), then pass that string to constructEvent. If you parse with req.json() first, the body is transformed and verification fails. That's the most common reason "webhook signature verification failed" shows up in logs.
Root cause, part two — no idempotency: there's no record of which event IDs you've already processed, so a retry runs the full handler again.
The fix: verify against the raw body, then dedupe on event.id before doing anything stateful.
import Stripe from "stripe";
import { headers } from "next/headers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, NOT req.json()
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Idempotency: record the event ID first. If it already exists,
// we've handled this delivery — acknowledge and stop.
const inserted = await recordEventIfNew(event.id);
if (!inserted) {
return new Response("Already processed", { status: 200 });
}
// Safe to mutate state now (provision access, update subscription, etc.)
// ...
return new Response("ok", { status: 200 });
}
recordEventIfNew is just an insert into a table with a unique constraint on the event ID — if the insert is rejected as a duplicate, you've already seen it. Keep the handler short; if the work is heavy, hand it off to a queue and keep the webhook fast and idempotent.
4. Server Action writes succeed, but the UI keeps showing old data
You submit a form, the database row updates (you can confirm it directly), but the page renders the previous value until you do a full reload — or sometimes not even then.
Root cause: the cached fetch that rendered the page and the path/tag you revalidated don't line up. Next.js served the page from cache, your mutation committed, but you either didn't revalidate or revalidated a path/tag that isn't the one tied to the data you're displaying. A near-relative of this bug: user-scoped data rendered on a static boundary, so it's cached across requests when it shouldn't be.
The fix: revalidate the exact path (or tag) that the displayed data is fetched under, from inside the Server Action.
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
export async function updateProjectName(id: string, name: string) {
const supabase = await createClient();
const { error } = await supabase
.from("projects")
.update({ name })
.eq("id", id);
if (error) throw error;
// Must match the route that renders this data.
revalidatePath("/dashboard/projects");
}
If you used fetch with a cache tag, revalidate that same tag with revalidateTag instead. The mismatch is the whole bug — line up the write path with the invalidation target and verify it in your server logs.
The full collection
These four are the ones I hit most, but the same "green build, wrong behavior" pattern shows up across RLS performance, migration rollbacks, edge function timeouts, OAuth callback loops, and more.
I've been collecting these into an open, curated list focused specifically on what breaks after you deploy — a symptom-first incident index (symptom → root cause → fix → reusable asset), a debugging playbook, production checklists, copy-ready snippets, RLS audit SQL, a Stripe webhook idempotency template, and vetted open-source examples:
https://github.com/mahdibrr/awesome-nextjs-supabase
It's a community resource — not affiliated with Vercel, Next.js, Supabase, or Stripe. If you've debugged a production failure mode that isn't in there yet, issues and PRs are very welcome. What's the worst "worked on localhost" bug this stack has thrown at you?
Notes de posting :
- Le code reflète les snippets vérifiés du repo et utilise des APIs réelles (
createServerClient,getUser,stripe.webhooks.constructEventavec rawreq.text(),revalidatePath). Aucune API/stat inventée. - Self-promo dev.to OK tant que l'article tient seul — celui-ci front-load 4 fixes réels avant le lien repo. Garde un seul CTA repo vers la fin, ne sème pas le lien partout.
- Timing : Tue–Thu matin US Eastern.
- La question finale sème les commentaires ; réponds vite dans les premières heures.
Top comments (0)