Supabase dengan Claude Code: Auth, RLS, Storage, dan Edge Functions
Bangun Supabase dengan Claude Code secara aman: Auth, RLS, Storage, Edge Functions, migration, testing, dan review.
Supabase sangat cepat untuk membuat prototipe, tetapi kecepatan itu bisa menipu. Jika kamu hanya meminta Claude Code “tambahkan login dan database”, hasilnya mungkin terlihat berjalan, tetapi Row Level Security, policy Storage, migration, dan session di server bisa belum aman.
Supabase adalah BaaS, Backend as a Service. Di dalamnya ada Postgres, Auth, Storage, Edge Functions, dan API yang otomatis dibuat. Untuk pemula, ini mengurangi pekerjaan backend. Untuk aplikasi produksi, nilai utamanya adalah izin akses bisa ditegakkan di Postgres melalui RLS, bukan hanya lewat kondisi di UI.
Artikel ini memakai Next.js App Router dan TypeScript. Kita akan membuat workflow Claude Code yang praktis: requirements file, review schema/RLS, perintah migration dan type generation, kode siap pakai, test command, jebakan umum, dan checklist sebelum rilis. Untuk konteks tambahan, baca juga authentication implementation, database design, dan database migration automation.
Mulai dari dokumentasi resmi
Gunakan dokumentasi resmi sebagai acuan: Supabase Docs, Auth, Row Level Security, Edge Functions, dan Storage.
Untuk proyek Next.js baru, gunakan @supabase/ssr untuk Auth berbasis cookie dan NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY untuk akses yang aman di browser. Proyek lama mungkin masih memakai anon key, tetapi implementasi baru sebaiknya memakai publishable key dan RLS yang eksplisit.
Arsitektur
Contohnya adalah fitur catatan proyek: user yang sudah login membuat catatan, mengunggah attachment privat, lalu memanggil Edge Function untuk tugas seperti notifikasi.
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"]
Batas keamanan berada di Postgres RLS dan Storage policy. UI boleh membantu pengalaman pengguna, tetapi tidak boleh menjadi satu-satunya lapisan otorisasi.
Requirements file untuk 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.
Mintalah Claude Code membuat SQL migration dulu, bukan UI. Dari catatan Masa, pendekatan UI-first lebih sering menghasilkan kode yang menerima owner_id dari form, padahal harus berasal dari user yang sedang login.
Setup dan environment
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
Jangan masukkan nilai asli ke prompt. Secret key dan service role key tidak boleh masuk ke NEXT_PUBLIC_ atau kode browser.
Migration dan 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);
Untuk Storage, bucket dibuat privat dan path folder pertama adalah user id.
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
Supabase clients
// 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, dan upload
// 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
Tiga use case
| Use case | Fitur Supabase | Tugas Claude Code | Review manusia |
|---|---|---|---|
| Catatan tim SaaS | Auth, Postgres, RLS | Tabel, Server Actions, UI daftar | Batas tim dan owner |
| Resource khusus member | Auth, Storage, signed URLs | Upload UI, download API, audit log | Bucket privat dan path |
| Reservasi event | Postgres, Edge Functions | Schema reservasi, notifikasi | Double booking dan retry |
Jebakan umum
RLS yang hanya diaktifkan tanpa policy akan memblokir semua akses. Policy using (true) yang terlalu luas justru membuka data. Test anonim, user login, dan user lain secara terpisah.
Secret key di client adalah kesalahan serius. Browser hanya boleh melihat publishable key.
Path Storage jangan ditentukan bebas oleh UI. Kode dan policy harus memakai bentuk yang sama, misalnya userId/random-file.
Setelah migration, generate ulang database.types.ts; kalau tidak, Claude Code akan menulis kode berdasarkan schema lama.
Edge Function tetap endpoint HTTP publik. Validasi Authorization dan gunakan client yang menghormati RLS kecuali ada alasan tertulis untuk bypass.
Prompt review
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.
Sebelum rilis, cek anonymous public read, authenticated private read, kegagalan update note milik user lain, kegagalan upload ke folder user lain, dan kegagalan memanggil Edge Function tanpa auth.
ClaudeCodeLab bisa membantu review integrasi Supabase + Claude Code, merapikan RLS, migrations, aturan CLAUDE.md, dan training tim. Untuk adopsi tim, lihat Claude Code training and consultation.
Saat workflow ini dicoba, manfaat terbesar datang dari review RLS migration sebelum UI. Dalam catatan Masa, pendekatan migration-first menghasilkan diff yang lebih kecil dan checklist rilis yang lebih jelas.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.