La maggior parte degli articoli su Next.js si ferma al confine della pagina. La parte interessante è cosa succede tra un Server Component, una Server Action e il database che tocca. Ecco la forma che usiamo in produzione per un signup isolato per tenant che chiama Stripe Checkout e scrive su Supabase sotto row-level security.
1. Il route file
Un Server Component legge il tenant corrente dalla sessione e mostra un form che fa POST verso una Server Action tipizzata. Niente client component, per ora: il form funziona anche con JavaScript disabilitato.
// 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>
Piano
<select name="plan" required>
<option value="starter">Starter</option>
<option value="growth">Growth</option>
</select>
</label>
<button type="submit">Vai al checkout</button>
</form>
)
}
2. La Server Action
L'action gira solo sul server. La chiamata Stripe avviene nel runtime Node; il redirect verso l'URL hosted di Stripe è un normale HTTP 303. Niente stato client, niente 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. La policy di row-level security
L'insert del tenant in stato draft gira con la service-role key, perché deve scrivere prima ancora che ci sia un utente. Quando il webhook Stripe collega un utente, ogni lettura successiva è ristretta al tenant autenticato via questa policy. La policy vive nel source control, non nella UI del database.
-- 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. Il webhook Stripe
Il webhook vive nel runtime Node perché gli serve il body raw per verificare la firma. È idempotente (Stripe può ritrasmettere) e scrive tramite un client service-role che le policy permettono perché l'operazione gira lato server senza un utente autenticato.
// 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. Cosa il client non vede
Non esiste un endpoint API che espone createCheckoutSession. Non esiste un fetch('/api/signup') in nessun client component. Non c'è un livello DTO che traduce tra tre forme dello stesso dato. Il confine della pagina è il confine dei dati, la Server Action è la mutazione e row-level security è l'unico livello di autorizzazione che si può sia verificare in audit sia far applicare.
Questo è quello che intendiamo per "Next.js in produzione": l'applicazione usa il framework, non gli gira attorno.
Approfondimenti