Back to skills
SKILL.md
Tpl Fullstack Supabase Nextjs
ASecurityTemplate do pack (fullstack/07-supabase-nextjs.md). Orienta o agente em stacks fullstack e arquitetura ponta a ponta alinhado a esse contexto.
- 11 stars
- 0 votes
- 0 copies
- 0 views
- Added September 8, 2026
Works with
Security analysis
100/100npx -y skills add artubss/SKILLS-CLAUDE-CODE --skill tpl-fullstack-supabase-nextjs --agent claude-codeAre you the author of Tpl Fullstack Supabase Nextjs?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/artubss-tpl-fullstack-supabase-nextjs)---
name: tpl-fullstack-supabase-nextjs
description: Template do pack (fullstack/07-supabase-nextjs.md). Orienta o agente em stacks fullstack e arquitetura ponta a ponta alinhado a esse contexto.
metadata:
version: 1.0.0
source_template: fullstack/07-supabase-nextjs.md
generated_by: install_pack_templates_as_claude_skills
---
# 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
1. **Two Supabase clients** — `client.ts` (browser, single instance) and `server.ts` (SSR, reads cookies); never mix them.
2. **Row Level Security is mandatory** — every table has RLS enabled with explicit `ENABLE ROW LEVEL SECURITY`; deny-by-default.
3. **Server client for mutations in Server Actions/Route Handlers** — always use server client for sensitive operations.
4. **Middleware refreshes sessions** — `middleware.ts` calls `supabase.auth.getUser()` on every request to refresh tokens.
5. **Type-safe via `database.types.ts`** — generated types used throughout; regenerate after every schema change.
6. **Storage buckets have policies** — no bucket is `public` without intent; RLS-like bucket policies enforced.
7. **Realtime subscriptions only in Client Components** — clean up with `useEffect` return function.
---
## SUPABASE CLIENT SETUP
```typescript
// 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
```sql
-- 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
```typescript
// 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
```sql
-- 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);
```
```typescript
// 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: false` unless intentionally public
- [ ] `playwright test` — E2E auth flow passes
- [ ] No `anon` key on server — use service role key only in trusted server contexts
---
## FORBIDDEN
- ❌ `supabase.auth.getSession()` for auth checks — use `getUser()` (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')` on `auth.users` — use `profiles` table exposed via RLS
- ❌ Realtime subscriptions opened in Server Components
- ❌ `POSTGREST_*` bypass headers in client queries
- ❌ `public` storage bucket without explicit policy review
Attribution
Related skills
View all in development →Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.