惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Martin Fowler
Martin Fowler
有赞技术团队
有赞技术团队
博客园_首页
H
Help Net Security
GbyAI
GbyAI
aimingoo的专栏
aimingoo的专栏
V
Visual Studio Blog
The Cloudflare Blog
腾讯CDC
Jina AI
Jina AI
Last Week in AI
Last Week in AI
月光博客
月光博客
博客园 - 叶小钗
Google DeepMind News
Google DeepMind News
B
Blog RSS Feed
Blog — PlanetScale
Blog — PlanetScale
人人都是产品经理
人人都是产品经理
Engineering at Meta
Engineering at Meta
Y
Y Combinator Blog
Hugging Face - Blog
Hugging Face - Blog
博客园 - 聂微东
爱范儿
爱范儿
N
Netflix TechBlog - Medium
F
Fortinet All Blogs

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
4 Next.js + Supabase bugs that only show up after you dep...
Mahdi BEN RHOUMA · 2026-06-02 · via DEV Community

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());

Enter fullscreen mode Exit fullscreen mode

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());

Enter fullscreen mode Exit fullscreen mode

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*"],
};

Enter fullscreen mode Exit fullscreen mode

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 });
}

Enter fullscreen mode Exit fullscreen mode

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");
}

Enter fullscreen mode Exit fullscreen mode

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.constructEvent avec raw req.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.