PROJECT: Next.js 15 + Supabase (Auth + DB + Storage + Realtime)
Skill gerado a partir do pack templates-claude-code. Arquivo de origem: fullstack/07-supabase-nextjs.md. Use como baseline e adapte ao projeto antes de mudancas grandes.
Conteudo do template
STACK
- Framework: Next.js 15 (App Router)
- Backend: Supabase (Auth, PostgreSQL, Storage, Realtime)
- Language: TypeScript 5.x (strict)
- Client: @supabase/ssr (Next.js-specific helpers)
- Styling: Tailwind CSS v3 + shadcn/ui
- Type generation: supabase gen types typescript
- Testing: Vitest + Playwright
PROJECT STRUCTURE
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── signup/page.tsx
│ ├── (protected)/
│ │ └── dashboard/page.tsx
│ └── api/
│ └── auth/
│ └── callback/route.ts # OAuth callback
├── lib/
│ ├── supabase/
│ │ ├── client.ts # Browser Supabase client
│ │ ├── server.ts # Server-side client (cookies)
│ │ └── middleware.ts # Session refresh middleware
│ └── utils.ts
├── components/
│ ├── AuthButton.tsx
│ └── RealtimePosts.tsx # Realtime subscription component
├── hooks/
│ └── useRealtimeSubscription.ts
└── types/
└── database.types.ts # Generated by supabase CLI
middleware.ts # Next.js middleware (session refresh)
supabase/
├── migrations/
│ └── 001_init.sql
└── seed.sql
ARCHITECTURE RULES
- Two Supabase clients —
client.ts(browser, single instance) andserver.ts(SSR, reads cookies); never mix them. - Row Level Security is mandatory — every table has RLS enabled with explicit
ENABLE ROW LEVEL SECURITY; deny-by-default. - Server client for mutations in Server Actions/Route Handlers — always use server client for sensitive operations.
- Middleware refreshes sessions —
middleware.tscallssupabase.auth.getUser()on every request to refresh tokens. - Type-safe via
database.types.ts— generated types used throughout; regenerate after every schema change. - Storage buckets have policies — no bucket is
publicwithout intent; RLS-like bucket policies enforced. - Realtime subscriptions only in Client Components — clean up with
useEffectreturn function.
SUPABASE CLIENT SETUP
// src/lib/supabase/client.ts (singleton browser client)
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '~/types/database.types'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
)
}
// src/lib/supabase/server.ts (server-side, reads/writes cookies)
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '~/types/database.types'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => request.cookies.getAll(), setAll: (c) => c.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options)) } }
)
await supabase.auth.getUser() // refreshes token automatically
return supabaseResponse
}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
ROW LEVEL SECURITY POLICIES
-- supabase/migrations/001_init.sql
-- Users can read all profiles; only own profile is updatable
CREATE TABLE profiles (
id UUID REFERENCES auth.users PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
avatar_url TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public profiles are viewable by everyone"
ON profiles FOR SELECT USING (true);
CREATE POLICY "Users can insert their own profile"
ON profiles FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY "Users can update their own profile"
ON profiles FOR UPDATE USING (auth.uid() = id);
-- Posts: public read; auth write own
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
author_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
published BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Published posts viewable by all"
ON posts FOR SELECT USING (published = true OR author_id = auth.uid());
CREATE POLICY "Authors can insert posts"
ON posts FOR INSERT WITH CHECK (auth.uid() = author_id);
CREATE POLICY "Authors can update their posts"
ON posts FOR UPDATE USING (auth.uid() = author_id);
CREATE POLICY "Authors can delete their posts"
ON posts FOR DELETE USING (auth.uid() = author_id);
REALTIME SUBSCRIPTION
// src/components/RealtimePosts.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '~/lib/supabase/client'
import type { Database } from '~/types/database.types'
type Post = Database['public']['Tables']['posts']['Row']
export function RealtimePosts({ initialPosts }: { initialPosts: Post[] }) {
const [posts, setPosts] = useState(initialPosts)
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel('posts-realtime')
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts', filter: 'published=eq.true' },
(payload) => {
setPosts(prev => [payload.new as Post, ...prev])
}
)
.on('postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'posts' },
(payload) => {
setPosts(prev => prev.filter(p => p.id !== payload.old.id))
}
)
.subscribe()
return () => { void supabase.removeChannel(channel) }
}, [supabase])
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
STORAGE BUCKET RULES
-- Storage: user avatars (private, own access only)
INSERT INTO storage.buckets (id, name, public) VALUES ('avatars', 'avatars', false);
CREATE POLICY "Avatar images are accessible by owner"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars' AND auth.uid() = owner);
CREATE POLICY "Users can upload their own avatar"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'avatars' AND auth.uid() = owner AND (storage.foldername(name))[1] = auth.uid()::text);
// Uploading a file (Server Action)
async function uploadAvatar(file: File) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const ext = file.name.split('.').pop()
const path = `${user.id}/avatar.${ext}`
const { error } = await supabase.storage.from('avatars').upload(path, file, { upsert: true })
if (error) throw error
const { data } = supabase.storage.from('avatars').getPublicUrl(path)
return data.publicUrl
}
ROUTING TABLE
| Method / Trigger | Path / Event | Auth | Action |
|---|---|---|---|
| GET | /login | No | Auth.js sign-in page |
| GET | /api/auth/callback | No | OAuth token exchange |
| GET | /dashboard | Yes | RSC with server client |
| POST | Server Action: createPost | Yes | Insert via server client |
| POST | Server Action: uploadAvatar | Yes | Upload to Storage |
| Realtime | posts INSERT | No (RLS) | Push to client state |
| Realtime | posts DELETE | No (RLS) | Remove from client state |
| GET | /storage/avatars/* | Yes (RLS) | Serve private avatar |
QUALITY GATES
-
supabase gen types typescript --local > src/types/database.types.ts— in sync -
tsc --noEmit— zero errors - Every table has
ENABLE ROW LEVEL SECURITY+ explicit policies -
supabase db push— migration applied to remote without errors - Realtime components clean up subscriptions on unmount
- Storage buckets are
public: falseunless intentionally public -
playwright test— E2E auth flow passes - No
anonkey on server — use service role key only in trusted server contexts
FORBIDDEN
- ❌
supabase.auth.getSession()for auth checks — usegetUser()(validates JWT server-side) - ❌ Service role key exposed in client code or
NEXT_PUBLIC_*env vars - ❌ Tables without RLS policies — never
DISABLE ROW LEVEL SECURITY - ❌ Browser Supabase client in Server Components or Route Handlers
- ❌ Direct
supabase.from('users')onauth.users— useprofilestable exposed via RLS - ❌ Realtime subscriptions opened in Server Components
- ❌
POSTGREST_*bypass headers in client queries - ❌
publicstorage bucket without explicit policy review