Advertisement
Verified Partner
Enterprise Cloud Infrastructure, GPU Clusters & AI APIs
Deploy high-throughput inference and quant trading pipelines with ultra-low latency.
Explore Platform →

Full-Stack Vibe Coding: Ship SaaS in Hours with Next.js 15, Supabase & Cursor Composer

By Sarah Lin • Intermediate • 20 min read • Updated 2026-09-12

What You Will Master in This 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.

MARKDOWN
# 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.
"""
Note: Golden Rule: List the exact files you want created or modified to prevent the AI from scattering logic into arbitrary directories.
Advertisement
Verified Partner
Quantitative Trading Systems & 30 AI Business Blueprints
Build predictable monthly recurring revenue with retainers & automated bots.
View Blueprints →

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.

SQL
-- 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);
Note: Security Warning: Never deploy an AI-generated database migration without verifying that ENABLE ROW LEVEL SECURITY is executed.

Knowledge Check: Test Your Understanding

1. Why is Row Level Security (RLS) critical when building with Supabase and AI?

3. Step by step: wire the dashboard to Supabase, end to end

Sections 1 and 2 generated the schema, the RLS policies, and the file layout. What is left is the part tutorials usually hand-wave: the actual application code that talks to Supabase. You will build it in three pieces — an SSR server client, a protected Server Component that reads metrics, and a Server Action that writes them — using exactly the table (developer_metrics) and policies the migration in section 2 created.

Step 1: create the SSR server client

Every server-side call goes through one factory that wires the anon key to the request cookies. This is the canonical @supabase/ssr pattern: the client reads the session from cookies, and Row Level Security — not your code — decides which rows the session may see. Because RLS is enforced in the database, the public anon key is safe to ship to the browser; the service-role key is not, and it never appears in this tutorial.

TYPESCRIPT
// 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).
          }
        },
      },
    }
  )
}

Step 2: read metrics in a protected Server Component

The dashboard page runs entirely on the server. It asks Supabase who the user is, redirects anonymous visitors to /login, and then selects the latest metrics. Notice what is missing: no user_id filter in the query. The SELECT policy from section 2 adds it automatically, so even a buggy page cannot leak another tenant’s rows.

TSX
// 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>
  )
}

Step 3: write rows through a validated Server Action

Writes go through a Server Action, and the payload is validated with Zod before it reaches the database — the type-safe pattern from this tutorial’s objectives. The INSERT policy double-checks that user_id matches auth.uid(), so a tampered client cannot write into someone else’s account.

TYPESCRIPT
// 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. A complete worked example

The three pieces work, but one gap remains: Server Components cannot write cookies, so an expiring session is never refreshed during a page render. Middleware closes that gap. It runs before every request, refreshes the auth token, and bounces anonymous users away from /dashboard before the page even loads:

TYPESCRIPT
// 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).*)'],
}

Trace one request through the finished example: a signed-in user opens /dashboard; middleware refreshes the session cookie and lets the request through; the Server Component builds the SSR client, confirms the user, and selects metrics that RLS silently scopes to that user; when the app records a new metric, the Server Action validates it with Zod, inserts it under the INSERT policy, and revalidates the page. Four trust boundaries, and the database enforces the two that matter most.

5. Troubleshooting

SQL
-- Which policies does Postgres actually enforce on the table?
select policyname, cmd, qual, with_check
from pg_policies
where tablename = 'developer_metrics';

Get 1 AI agent blueprint every week

Join the free PVT newsletter: one practical AI agent or automation you can build and sell, every week. No spam.