La mayoría de los artículos sobre Next.js se quedan en el borde de la página. La parte interesante es qué pasa entre un Server Component, una Server Action y la base de datos que tocan. Esta es la forma que usamos en producción para un signup aislado por tenant que llama a Stripe Checkout y escribe sobre Supabase bajo row-level security.
1. El route file
Un Server Component lee el tenant actual desde la sesión y muestra un formulario que hace POST a una Server Action tipada. Sin client component, por ahora: el formulario funciona incluso con JavaScript deshabilitado.
// 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">Ir al checkout</button>
</form>
)
}
2. La Server Action
La action corre solo en el servidor. La llamada a Stripe ocurre en el runtime Node; el redirect al URL hosted de Stripe es un HTTP 303 normal. Sin estado en client, sin 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 de row-level security
El insert del tenant en estado draft corre con la service-role key, porque tiene que escribir antes incluso de que exista un usuario. Cuando el webhook de Stripe vincula un usuario, cada lectura siguiente queda acotada al tenant autenticado vía esta policy. La policy vive en source control, no en la UI de la base de datos.
-- 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. El webhook de Stripe
El webhook vive en el runtime Node porque necesita el body raw para verificar la firma. Es idempotente (Stripe puede reintentar) y escribe a través de un client service-role que las policies permiten porque la operación corre en servidor sin un usuario autenticado.
// 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. Lo que el client nunca ve
No existe un endpoint API que exponga createCheckoutSession. No hay un fetch('/api/signup') en ningún client component. No hay una capa DTO que traduce entre tres formas del mismo dato. El borde de la página es el borde de los datos, la Server Action es la mutación, y row-level security es la única capa de autorización que se puede auditar y aplicar al mismo tiempo.
Esto es lo que entendemos por "Next.js en producción": la aplicación usa el framework, no gira alrededor de él.
Lecturas relacionadas