GetLaunchpad
Back to blog

How to wire Clerk, Stripe, and Supabase together in Next.js 16

A deep dive into the architecture decisions behind GetLaunchpad — three Supabase client tiers, the Stripe webhook pipeline, Clerk middleware in Next.js 16, and why embedded checkout beats redirects.

Building a SaaS product on Next.js 16 means making a handful of interconnected architecture decisions upfront — decisions that are cheap to get right at the start and expensive to retrofit later. After wiring Clerk, Stripe, and Supabase together for GetLaunchpad, here is an honest account of the choices that mattered.

Route protection with Clerk in Next.js 16

Clerk's Next.js SDK wraps the standard middleware mechanism, but Next.js 16 made one quiet breaking change: the middleware file was renamed from middleware.ts to proxy.ts. Every tutorial you find online still references the old name, so if your protected routes suddenly expose themselves in production, this is the first place to check.

Inside proxy.ts, the setup pairs clerkMiddleware with createRouteMatcher to declare which paths require a valid session:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isProtected = createRouteMatcher(["/dashboard(.*)"]);

export default clerkMiddleware((auth, req) => {
  if (isProtected(req)) auth().protect();
});

export const config = {
  matcher: ["/((?!_next|.*\..*).*)"],
};

Everything under /dashboard is protected. Public routes — the landing page, pricing, blog, legal pages — live in the (marketing) route group and are never touched by the auth check. This separation keeps the matcher regex simple and avoids accidentally blocking static assets.

On first dashboard load the client fires a POST /api/user request that upserts the Clerk user into Supabase. That bridge is the only place where a Clerk identity becomes a database row — you want exactly one write path, not three.

Three Supabase client tiers — and why you need all three

Supabase gives you Row Level Security for free, but RLS is only useful if you call the database as the right principal. GetLaunchpad ships three separate client files for exactly this reason.

1. Browser client

lib/supabase/client.tsuses the public anon key and is safe to import in client components. RLS policies run against the authenticated user's JWT. If a user can only read their own rows, this client enforces that automatically — you don't write filter conditions by hand.

2. Server client

lib/supabase/server.ts also uses the anon key, but reads the session from cookies (via next/headers) instead of browser storage. Use this in Server Components and Route Handlers where you need RLS still enforced but there is no browser context.

3. Admin client

lib/supabase/admin.ts uses the service role key and bypasses RLS entirely. This is the only client that should ever touch the subscriptions table from server-side, because subscription writes come from Stripe webhooks — not from a logged-in user session. Importing this client in a browser component would leak your service role key into the JavaScript bundle; the file is marked server-only to make that a build error rather than a runtime surprise.

The rule of thumb: start with the browser or server client. Reach for the admin client only when you are acting on behalf of the system, not on behalf of a user.

The Stripe webhook pipeline

Stripe's event model is append-only and out-of-order. A customer.subscription.updated event might arrive before the corresponding checkout.session.completed event if your webhook handler restarts mid-flight. The implementation in GetLaunchpad handles this with an upsert pattern rather than separate insert and update paths.

The flow across three endpoints:

  1. POST /api/stripe/checkout — creates a Stripe Checkout Session in embedded mode and returns the client_secret to the frontend. Rate-limited via Upstash Redis with a sliding window so a user cannot hammer the endpoint and create orphaned sessions. The session is created with ui_mode: "embedded" so the payment form renders inline on your page, not on stripe.com.
  2. POST /api/stripe/webhook — the only place subscription state is written to the database. On checkout.session.completed and customer.subscription.updated, the handler upserts into the subscriptions table using the admin client. On customer.subscription.deleted it sets status = 'canceled'. Each event also triggers a Resend email — welcome on activation, cancellation notice on deletion — so your users are never left wondering what just happened to their billing.
  3. POST /api/stripe/portal— generates a Billing Portal session URL and redirects the authenticated user there. Subscription management (plan changes, cancellation) all happens inside Stripe's hosted portal, which means you never touch payment methods or cancellation UI yourself.

One thing worth calling out: always verify the Stripe webhook signature with stripe.webhooks.constructEvent before processing the event body. Stripe sends a Stripe-Signature header that ties the payload to your webhook secret. Without this check, any HTTP client that knows your webhook URL can feed fake events into your database.

Why embedded checkout instead of redirects

Stripe's hosted checkout (the classic redirect approach) sends users to a checkout.stripe.com URL. That works fine but costs you conversion: every page transition is a chance to lose someone. More importantly, it makes your product feel unfinished — users notice when they suddenly land on a third-party domain mid-flow.

Embedded checkout (ui_mode: "embedded") keeps the entire transaction on your domain. The frontend fetches a client_secretfrom your checkout API route, then mounts Stripe's prebuilt UI inside your own page using @stripe/react-stripe-js. Stripe still handles card tokenization, 3DS challenges, and PCI compliance — you just never leave the page. The tradeoff is a small amount of frontend wiring, which GetLaunchpad ships pre-built.

Keeping the Supabase schema honest

The database schema lives in supabase/schema.sql and is checked into version control. Two tables: users (keyed on clerk_id) and subscriptions (keyed on stripe_customer_id). Both have RLS enabled at the table level.

Keeping schema in SQL — rather than in a migration tool with its own abstraction layer — means a new contributor can read one file and understand the entire data model. It also means your Supabase project can be fully reproduced from the repo: open the SQL editor, paste the file, done.

Putting it all together

The pattern that emerges from all of this is straightforward: Clerk owns identity, Stripe owns money, and Supabase owns state. Each service has a narrow interface. Clerk never talks to Stripe directly. Stripe writes to Supabase only through the webhook handler. The browser never gets a service role key.

When the seams are this clean, debugging is fast. An authentication problem is a Clerk problem. A payment problem is a Stripe problem. A data problem is a Supabase problem. You are never hunting across three services at once trying to figure out which one has the authoritative state.


All of this — the three Supabase tiers, the Stripe webhook pipeline, Clerk middleware in proxy.ts, embedded checkout, and the production-ready schema — is pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and skip straight to building your product.