Most Next.js write-ups stop at the page boundary. The interesting part is what happens between a Server Component, a Server Action and the database it touches. Here is the shape we use in production for a tenant-isolated signup that calls Stripe Checkout and writes back to Supabase under row-level security.
1. The route file
A single Server Component reads the current tenant from the session and renders a form that posts to a typed Server Action. No client component yet; the form works with JavaScript disabled.
// app/[lang]/(public)/signup/page.tsx
import { redirect } from 'next/navigation'
import { createCheckoutSession } from './actions'
import { getServerSession } from '@/lib/auth/server'
export default async function SignupPage() {
const session = await getServerSession()
if (session) redirect('/dashboard')
return (
<form action={createCheckoutSession}>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Plan
<select name="plan" required>
<option value="starter">Starter</option>
<option value="growth">Growth</option>
</select>
</label>
<button type="submit">Continue to checkout</button>
</form>
)
}
2. The Server Action
The action runs only on the server. The Stripe call happens in the Node runtime; the redirect to the Stripe-hosted checkout URL is a regular HTTP 303. No client state, no fetch wrapper.
// app/[lang]/(public)/signup/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { stripe } from '@/lib/stripe/server'
import { createDraftTenant } from '@/lib/tenants/server'
const Input = z.object({
email: z.string().email(),
plan: z.enum(['starter', 'growth']),
})
export async function createCheckoutSession(formData: FormData) {
const parsed = Input.parse({
email: formData.get('email'),
plan: formData.get('plan'),
})
const tenant = await createDraftTenant(parsed.email)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: parsed.email,
client_reference_id: tenant.id,
line_items: [{ price: PRICE_LOOKUP[parsed.plan], quantity: 1 }],
success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/signup`,
metadata: { tenant_id: tenant.id, plan: parsed.plan },
})
redirect(session.url!)
}
3. The row-level-security policy
The draft tenant insert runs as the service-role key so it can write before there is a user. Once the Stripe webhook attaches a user, every subsequent read is scoped to the authenticated tenant via this policy. The policy is in source control, not in the database UI.
-- supabase/migrations/0001_tenants_rls.sql
create policy "tenants: read own row"
on tenants for select
using (
id = (select tenant_id from members where user_id = auth.uid())
);
create policy "tenants: update own row"
on tenants for update
using (
id = (select tenant_id from members where user_id = auth.uid())
and exists (
select 1 from members
where user_id = auth.uid()
and tenant_id = tenants.id
and role in ('owner', 'admin')
)
);
4. The Stripe webhook
The webhook lives in the Node runtime because it needs the raw request body for signature verification. It is idempotent (Stripe may retry) and writes back through a service-role client that the policies allow because the operation runs server-side without an authenticated user yet.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { promoteDraftTenant } from '@/lib/tenants/server'
export const runtime = 'nodejs'
export async function POST(request: Request) {
const signature = (await headers()).get('stripe-signature')
if (!signature) return new Response('missing signature', { status: 400 })
const body = await request.text()
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response('invalid signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
await promoteDraftTenant({
tenantId: session.client_reference_id!,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
})
}
return new Response(null, { status: 200 })
}
5. What the client never sees
There is no API endpoint exposing createCheckoutSession. There is no fetch('/api/signup') in any client component. There is no DTO layer translating between three shapes of the same data. The page boundary is the data boundary, the Server Action is the mutation, and row-level security is the only authorisation layer that can be both audited and enforced.
This is what we mean by "production Next.js": the application uses the framework, not around it.
Further reading