# Tpl Fullstack Supabase Nextjs

> Template do pack (fullstack/07-supabase-nextjs.md). Orienta o agente em stacks fullstack e arquitetura ponta a ponta alinhado a esse contexto.

- Skill: `artubss/tpl-fullstack-supabase-nextjs` (Agent Skill)
- Install (CLI): `npx skillmds@latest add artubss/tpl-fullstack-supabase-nextjs`
- Raw SKILL.md: https://api.skillmd.com/api/skills/artubss/tpl-fullstack-supabase-nextjs/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: artubss (https://skillmd.com/u/artubss)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/artubss/tpl-fullstack-supabase-nextjs

---


# 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

