Vibe Coding Full-Stack: Construye un SaaS con Next.js, Supabase y Cursor
Lo Que Dominarás en Este Tutorial
- Configure Cursor Composer to orchestrate multi-file full-stack edits across frontend and backend.
- Prompt AI to generate relational PostgreSQL schemas with strict Supabase Row Level Security (RLS).
- Implement type-safe Server Actions with Zod validation and optimistic UI updates.
- Safely handle Stripe webhook lifecycle events and database synchronizations.
1. Architecting the Multi-File Prompt in Cursor Composer
Cursor Composer allows editing dozens of project files simultaneously. To get production-grade output, structure your master prompt with clear architectural roles, dependency versions, and security constraints.
# Master Composer Prompt:
"""
You are an expert Next.js 15 and Supabase architect.
Task: Build an authenticated subscription dashboard for our developer analytics platform.
Files to create / modify:
1. app/dashboard/page.tsx: Server component fetching metrics from Supabase.
2. app/actions/billing.ts: Server Action creating a Stripe customer portal session.
3. supabase/migrations/20260912_metrics.sql: Table definition with strict RLS policies.
Requirements:
- Next.js 15 App Router with TypeScript strict mode.
- Use Supabase SSR client (@supabase/ssr).
- Enforce RLS: users can only SELECT and INSERT rows matching auth.uid().
- Handle loading and error boundaries gracefully.
"""
2. Bulletproof Supabase RLS Generation
AI models frequently forget database security unless explicitly prompted. Always verify that generated migrations include Row Level Security and explicit tenant isolation policies.
-- Generated Supabase Migration: 20260912_metrics.sql
CREATE TABLE public.developer_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
api_calls_count INTEGER NOT NULL DEFAULT 0,
latency_p99_ms NUMERIC(6,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enable Row Level Security (Mandatory)
ALTER TABLE public.developer_metrics ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only read their own metrics
CREATE POLICY "Users can read own metrics"
ON public.developer_metrics
FOR SELECT
USING (auth.uid() = user_id);
-- Policy: Users can insert their own metrics
CREATE POLICY "Users can insert own metrics"
ON public.developer_metrics
FOR INSERT
WITH CHECK (auth.uid() = user_id);
Evaluación Rápida: Pon a Prueba tus Conocimientos
3. Paso a paso: conecta el panel con Supabase, de principio a fin
En las secciones 1 y 2 ya generaste el esquema, las políticas de RLS y la estructura de archivos. Lo que falta es justo lo que la mayoría de los tutoriales suele pasar por alto: el código real de la aplicación que se comunica con Supabase. Lo vas a construir en tres piezas —un cliente de servidor para SSR, un Server Component protegido que lee las métricas y una Server Action que las escribe— usando exactamente la tabla (developer_metrics) y las políticas que creó la migración de la sección 2.
Paso 1: crea el cliente de servidor SSR
Toda llamada desde el servidor pasa por una única fábrica que conecta la clave anónima con las cookies de la petición. Es el patrón canónico de @supabase/ssr: el cliente lee la sesión desde las cookies y es la seguridad a nivel de fila (Row Level Security), y no tu código, la que decide qué filas puede ver esa sesión. Como RLS se aplica dentro de la base de datos, la clave anónima pública se puede enviar al navegador sin riesgo; la clave de rol de servicio, en cambio, no, y no aparece en ningún momento de este tutorial.
// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component: safe to ignore when
// middleware is refreshing the session (see step 4).
}
},
},
}
)
}
Paso 2: lee las métricas en un Server Component protegido
La página del panel se ejecuta por completo en el servidor. Le pregunta a Supabase quién es el usuario, redirige a los visitantes anónimos a /login y después selecciona las métricas más recientes. Fíjate en lo que no está: no hay ningún filtro por user_id en la consulta. La política SELECT de la sección 2 lo añade automáticamente, así que ni siquiera una página con un error podría filtrar las filas de otro inquilino.
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: metrics, error } = await supabase
.from('developer_metrics')
.select('id, api_calls_count, latency_p99_ms, created_at')
.order('created_at', { ascending: false })
.limit(20)
if (error) throw new Error(error.message)
return (
<main>
<h1>Metrics for {user.email}</h1>
<ul>
{metrics.map((m) => (
<li key={m.id}>
{new Date(m.created_at).toLocaleString()} - {m.api_calls_count} calls,
p99 {m.latency_p99_ms} ms
</li>
))}
</ul>
</main>
)
}
Paso 3: escribe filas mediante una Server Action validada
Las escrituras pasan por una Server Action y los datos se validan con Zod antes de llegar a la base de datos: ese es el patrón con seguridad de tipos que plantean los objetivos de este tutorial. La política INSERT comprueba además que user_id coincida con auth.uid(), de modo que un cliente manipulado no pueda escribir en la cuenta de otra persona.
// app/actions/metrics.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/utils/supabase/server'
const metricSchema = z.object({
apiCallsCount: z.number().int().min(0),
latencyP99Ms: z.number().positive(),
})
export async function logMetric(input: unknown) {
const parsed = metricSchema.safeParse(input)
if (!parsed.success) throw new Error('Invalid metric payload')
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const { error } = await supabase.from('developer_metrics').insert({
user_id: user.id,
api_calls_count: parsed.data.apiCallsCount,
latency_p99_ms: parsed.data.latencyP99Ms,
})
if (error) throw new Error(error.message)
revalidatePath('/dashboard')
}
4. Un ejemplo completo
Las tres piezas funcionan, pero todavía queda un hueco: los Server Components no pueden escribir cookies, así que una sesión a punto de caducar nunca se renueva durante el renderizado de una página. El middleware cierra ese hueco. Se ejecuta antes de cada petición, renueva el token de autenticación y aleja a los usuarios anónimos de /dashboard antes incluso de que la página empiece a cargar:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
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)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Sigue el recorrido de una petición en el ejemplo terminado: un usuario con la sesión iniciada abre /dashboard; el middleware renueva la cookie de sesión y deja pasar la petición; el Server Component crea el cliente SSR, confirma quién es el usuario y selecciona unas métricas que RLS limita en silencio a ese usuario; cuando la aplicación registra una métrica nueva, la Server Action la valida con Zod, la inserta bajo la política INSERT y vuelve a validar la página. Son cuatro fronteras de confianza, y la base de datos hace cumplir las dos más importantes.
5. Solución de errores
- El panel se muestra vacío aunque sí existen filas. Casi siempre se trata de un desajuste de RLS: o nunca se creó la política SELECT, o esta compara user_id con un valor equivocado. Revisa lo que Postgres tiene realmente con la consulta de abajo, ejecutada en el editor SQL de Supabase, y confirma que existe una política por cada operación que utilizas.
- Bucle infinito de redirecciones entre /login y /dashboard. La sesión no se está renovando, así que getUser() devuelve null en cada petición. Comprueba que middleware.ts esté en la raíz del proyecto (junto a app/, no dentro de esa carpeta) y que el matcher no esté excluyendo la ruta del panel.
- Error 401 «Invalid API key» de Supabase. Las variables de entorno tienen un nombre incorrecto o están desactualizadas. Deben llamarse exactamente NEXT_PUBLIC_SUPABASE_URL y NEXT_PUBLIC_SUPABASE_ANON_KEY en .env.local, sin comillas alrededor, y hay que reiniciar el servidor de desarrollo después de cualquier cambio, porque Next.js solo lee los archivos de entorno al arrancar.
- Cursor reescribió el cliente de servidor como un cliente de navegador con la clave de rol de servicio. El prompt de Composer se desvió de la estructura de archivos de la sección 1. Vuelve a fijar la arquitectura en el prompt (cliente de servidor en utils/supabase/server.ts, solo @supabase/ssr), elimina el archivo generado y vuelve a generarlo. La clave de rol de servicio se salta RLS por completo; si alguna vez llegó al navegador, rota la clave desde el panel de Supabase.
-- Which policies does Postgres actually enforce on the table?
select policyname, cmd, qual, with_check
from pg_policies
where tablename = 'developer_metrics';