Supabase con Claude Code: Auth, RLS, Storage y Edge Functions paso a paso
Implementa Supabase con Claude Code de forma segura: Auth, RLS, Storage, Edge Functions, migraciones, tests y revisión.
Supabase permite avanzar muy rápido, pero esa rapidez puede ocultar problemas serios. Si pides a Claude Code “añade login y base de datos”, puede generar una pantalla funcional sin Row Level Security, sin policies de Storage, sin disciplina de migraciones y sin manejo correcto de sesiones en servidor.
Supabase es un BaaS, Backend as a Service. Reúne Postgres, Auth, Storage, Edge Functions y APIs generadas. Para principiantes reduce infraestructura; para producción, su mayor valor es que permite hacer cumplir permisos en Postgres con RLS, no solo con condiciones en la UI.
Esta guía usa Next.js App Router y TypeScript. Incluye un flujo práctico para Claude Code: archivo de requisitos, revisión de schema/RLS, comandos de migración y tipos, bloques de código copiables, pruebas, errores frecuentes y checklist final. También conviene leer implementación de autenticación, diseño de base de datos y migraciones de base de datos.
Documentación oficial
Usa como base Supabase Docs, Auth, Row Level Security, Edge Functions y Storage.
En proyectos Next.js nuevos, @supabase/ssr es la opción práctica para Auth basada en cookies. Para el navegador, usa NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. Los proyectos antiguos pueden tener anon keys, pero el contenido nuevo debe empujar hacia publishable keys y RLS explícito.
Arquitectura
El ejemplo es una función de notas: un usuario autenticado crea sus notas, sube un adjunto privado y llama una Edge Function para una tarea de notificación.
flowchart LR
User["Browser"] --> Next["Next.js App Router"]
Next --> SSR["@supabase/ssr client"]
SSR --> Auth["Supabase Auth"]
SSR --> DB["Postgres tables"]
SSR --> Storage["Storage bucket"]
Next --> Fn["Edge Function"]
Fn --> DB
DB --> RLS["RLS policies"]
Storage --> StorageRLS["storage.objects policies"]
La frontera de seguridad está en Postgres y Storage policies. La UI ayuda, pero no debe ser la única defensa.
Archivo de requisitos para Claude Code
# docs/supabase-notes-requirements.md
## Goal
Build a Supabase-backed project notes feature in Next.js App Router.
## Stack
- Next.js App Router
- TypeScript
- @supabase/supabase-js
- @supabase/ssr
- Supabase Auth, Postgres, Storage, Edge Functions
## Data model
- project_notes table
- Each note belongs to auth.users.id through owner_id
- Public notes are readable by anyone
- Private notes are readable only by the owner
- Owners can insert, update, and delete only their own notes
## Security rules
- Never expose a secret key or service role key in browser code
- Use NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for browser and SSR clients
- Enable RLS on every public table
- Use explicit TO anon or TO authenticated in every policy
- Storage uploads must be restricted to a user-owned folder
## Claude Code workflow
1. Create SQL migration first.
2. Review RLS policies before writing UI.
3. Generate TypeScript database types.
4. Implement Supabase clients.
5. Implement server actions and upload helper.
6. Add test or manual verification commands.
7. Return a review checklist with file paths.
Pide primero solo la migración SQL. En las pruebas de Masa, empezar por la UI aumentaba la probabilidad de aceptar owner_id desde un formulario.
Instalación y entorno
npm install @supabase/supabase-js @supabase/ssr zod
npm install --save-dev supabase vitest
npx supabase init
npx supabase start
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxxxxxxxxxx
No pegues valores reales en prompts. Las secret keys y service role keys deben quedar en servidor y documentarse si saltan RLS.
Migration y RLS
-- supabase/migrations/202606010001_create_project_notes.sql
create table if not exists public.project_notes (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
body text not null default '',
visibility text not null default 'private'
check (visibility in ('private', 'public')),
attachment_path text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists project_notes_owner_created_idx
on public.project_notes (owner_id, created_at desc);
alter table public.project_notes enable row level security;
create policy "Anyone can read public notes"
on public.project_notes
for select
to anon, authenticated
using (
visibility = 'public'
or (select auth.uid()) = owner_id
);
create policy "Owners can insert notes"
on public.project_notes
for insert
to authenticated
with check ((select auth.uid()) = owner_id);
create policy "Owners can update notes"
on public.project_notes
for update
to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "Owners can delete notes"
on public.project_notes
for delete
to authenticated
using ((select auth.uid()) = owner_id);
Para Storage, limita el bucket privado a una carpeta del usuario.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'note-attachments',
'note-attachments',
false,
5242880,
array['image/png', 'image/jpeg', 'application/pdf']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy "Users can upload own note attachments"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
create policy "Users can read own note attachments"
on storage.objects
for select
to authenticated
using (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
npx supabase db reset
npx supabase gen types typescript --local > src/lib/database.types.ts
npm run typecheck
Clientes Supabase
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Server Components cannot set cookies directly.
}
},
},
},
);
}
Auth, CRUD y subida
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function signIn(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
redirect("/dashboard");
}
// src/features/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type CreateNoteInput = {
title: string;
body: string;
visibility?: "private" | "public";
attachmentPath?: string | null;
};
export async function createNote(input: CreateNoteInput) {
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) throw new Error("Authentication required");
const { data, error } = await supabase
.from("project_notes")
.insert({
owner_id: user.id,
title: input.title,
body: input.body,
visibility: input.visibility ?? "private",
attachment_path: input.attachmentPath ?? null,
})
.select("id,title,visibility")
.single();
if (error) throw error;
revalidatePath("/dashboard");
return data;
}
// src/features/notes/upload-note-attachment.ts
"use client";
import { createClient } from "@/lib/supabase/client";
export async function uploadNoteAttachment(file: File, userId: string) {
const supabase = createClient();
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const path = `${userId}/${crypto.randomUUID()}.${ext}`;
const { error } = await supabase.storage
.from("note-attachments")
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (error) throw error;
return path;
}
Edge Function
npx supabase functions new notify-note-created
// supabase/functions/notify-note-created/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const authorization = req.headers.get("Authorization");
if (!authorization) {
return Response.json({ error: "Missing authorization" }, { status: 401 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authorization } } },
);
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
return Response.json({ ok: true, userId: user.id });
});
npx supabase functions serve notify-note-created --env-file .env.local
npx supabase functions deploy notify-note-created
Tres casos de uso
| Caso | Funciones Supabase | Tarea para Claude Code | Revisión humana |
|---|---|---|---|
| Notas de equipo SaaS | Auth, Postgres, RLS | Tablas, Server Actions, UI | Límite de equipo y propietario |
| Recursos de miembros | Auth, Storage, signed URLs | Upload, descarga, auditoría | Bucket privado y rutas |
| Reservas de eventos | Postgres, Edge Functions | Esquema, notificación, cancelación | Doble reserva y reintentos |
Errores frecuentes
Activar RLS sin policies bloquea todo. Usar using (true) demasiado amplio expone filas. Prueba anónimo, usuario autenticado y otro usuario.
Una secret key en cliente es un fallo grave. El navegador solo debe ver publishable keys.
Las rutas de Storage no deben ser libres. Código y policy deben compartir una forma como userId/random-file.
Después de cada migración, regenera database.types.ts; si no, Claude Code seguirá escribiendo contra un schema antiguo.
Las Edge Functions son endpoints HTTP públicos. Valida Authorization y usa un cliente que respete RLS salvo que haya una justificación documentada.
Prompt de revisión
Review only the Supabase integration.
Check RLS, explicit TO roles, publishable key usage, no secret key in client code,
owner_id derived from authenticated user, Storage paths matching policies,
generated types in sync, and Edge Functions validating Authorization.
Return findings with file paths and line numbers.
Antes de publicar, comprueba lecturas públicas anónimas, lecturas privadas autenticadas, fallo al actualizar notas ajenas, fallo al subir a carpetas ajenas y fallo al llamar una Edge Function sin autenticación.
ClaudeCodeLab puede revisar implementaciones Supabase + Claude Code, ordenar RLS, migraciones, reglas CLAUDE.md y formación de equipos. Para adopción en equipo, usa la página de Claude Code training and consultation.
Al probar este flujo, el mayor beneficio fue revisar la migración RLS antes de construir la UI. En las notas de Masa, el enfoque migration-first produjo diffs más pequeños y controles de release más claros.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.