supabase-nextjs

Next.js with Supabase and Drizzle ORM

INSTALLATION
npx skills add https://github.com/alinaqi/claude-bootstrap --skill supabase-nextjs
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Supabase + Next.js Skill

Next.js App Router patterns with Supabase Auth and Drizzle ORM.

Sources: Supabase Next.js Guide | Drizzle + Supabase

Core Principle

Drizzle for queries, Supabase for auth/storage, server components by default.

Use Drizzle ORM for type-safe database access. Use Supabase client for auth, storage, and realtime. Prefer server components; use client components only when needed.

Project Structure

project/

├── src/

│   ├── app/

│   │   ├── (auth)/

│   │   │   ├── login/page.tsx

│   │   │   ├── signup/page.tsx

│   │   │   └── callback/route.ts

│   │   ├── (dashboard)/

│   │   │   └── page.tsx

│   │   ├── api/

│   │   │   └── [...]/route.ts

│   │   ├── layout.tsx

│   │   └── page.tsx

│   ├── components/

│   │   ├── auth/

│   │   └── ui/

│   ├── db/

│   │   ├── index.ts              # Drizzle client

│   │   ├── schema.ts             # Schema definitions

│   │   └── queries/              # Query functions

│   ├── lib/

│   │   ├── supabase/

│   │   │   ├── client.ts         # Browser client

│   │   │   ├── server.ts         # Server client

│   │   │   └── middleware.ts     # Auth middleware helper

│   │   └── auth.ts               # Auth helpers

│   └── middleware.ts             # Next.js middleware

├── supabase/

│   ├── migrations/

│   └── config.toml

├── drizzle.config.ts

└── .env.local

Setup

Install Dependencies

npm install @supabase/supabase-js @supabase/ssr drizzle-orm postgres

npm install -D drizzle-kit

Environment Variables

# .env.local

NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321

NEXT_PUBLIC_SUPABASE_ANON_KEY=<from supabase start>

# Server-side only

SUPABASE_SERVICE_ROLE_KEY=<from supabase start>

DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres

Drizzle Setup

drizzle.config.ts

import { defineConfig } from 'drizzle-kit';

export default defineConfig({

  schema: './src/db/schema.ts',

  out: './supabase/migrations',

  dialect: 'postgresql',

  dbCredentials: {

    url: process.env.DATABASE_URL!,

  },

  schemaFilter: ['public'],

});

src/db/index.ts

import { drizzle } from 'drizzle-orm/postgres-js';

import postgres from 'postgres';

import * as schema from './schema';

const client = postgres(process.env.DATABASE_URL!, {

  prepare: false, // Required for Supabase connection pooling

});

export const db = drizzle(client, { schema });

src/db/schema.ts

import {

  pgTable,

  uuid,

  text,

  timestamp,

  boolean,

} from 'drizzle-orm/pg-core';

export const profiles = pgTable('profiles', {

  id: uuid('id').primaryKey(), // References auth.users

  email: text('email').notNull(),

  name: text('name'),

  avatarUrl: text('avatar_url'),

  createdAt: timestamp('created_at').defaultNow().notNull(),

  updatedAt: timestamp('updated_at').defaultNow().notNull(),

});

export const posts = pgTable('posts', {

  id: uuid('id').primaryKey().defaultRandom(),

  authorId: uuid('author_id').references(() => profiles.id).notNull(),

  title: text('title').notNull(),

  content: text('content'),

  published: boolean('published').default(false),

  createdAt: timestamp('created_at').defaultNow().notNull(),

});

// Type exports

export type Profile = typeof profiles.$inferSelect;

export type NewProfile = typeof profiles.$inferInsert;

export type Post = typeof posts.$inferSelect;

export type NewPost = typeof posts.$inferInsert;

Supabase Clients

src/lib/supabase/client.ts (Browser)

import { createBrowserClient } from '@supabase/ssr';

export function createClient() {

  return createBrowserClient(

    process.env.NEXT_PUBLIC_SUPABASE_URL!,

    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

  );

}

src/lib/supabase/server.ts (Server Components/Actions)

import { createServerClient } from '@supabase/ssr';

import { cookies } from 'next/headers';

export async function createClient() {

  const cookieStore = await cookies();

  return createServerClient(

    process.env.NEXT_PUBLIC_SUPABASE_URL!,

    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,

    {

      cookies: {

        getAll() {

          return cookieStore.getAll();

        },

        setAll(cookiesToSet) {

          try {

            cookiesToSet.forEach(({ name, value, options }) =>

              cookieStore.set(name, value, options)

            );

          } catch {

            // Called from Server Component - ignore

          }

        },

      },

    }

  );

}

src/lib/supabase/middleware.ts (For Middleware)

import { createServerClient } from '@supabase/ssr';

import { NextResponse, type NextRequest } from 'next/server';

export async function updateSession(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() {

          return request.cookies.getAll();

        },

        setAll(cookiesToSet) {

          cookiesToSet.forEach(({ name, value }) =>

            request.cookies.set(name, value)

          );

          supabaseResponse = NextResponse.next({ request });

          cookiesToSet.forEach(({ name, value, options }) =>

            supabaseResponse.cookies.set(name, value, options)

          );

        },

      },

    }

  );

  // Refresh session

  const { data: { user } } = await supabase.auth.getUser();

  return { supabaseResponse, user };

}

Middleware

src/middleware.ts

import { type NextRequest, NextResponse } from 'next/server';

import { updateSession } from '@/lib/supabase/middleware';

const publicRoutes = ['/', '/login', '/signup', '/auth/callback'];

export async function middleware(request: NextRequest) {

  const { supabaseResponse, user } = await updateSession(request);

  const isPublicRoute = publicRoutes.some(route =>

    request.nextUrl.pathname.startsWith(route)

  );

  // Redirect unauthenticated users to login

  if (!user &#x26;&#x26; !isPublicRoute) {

    const url = request.nextUrl.clone();

    url.pathname = '/login';

    url.searchParams.set('redirectTo', request.nextUrl.pathname);

    return NextResponse.redirect(url);

  }

  // Redirect authenticated users away from auth pages

  if (user &#x26;&#x26; (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {

    return NextResponse.redirect(new URL('/dashboard', request.url));

  }

  return supabaseResponse;

}

export const config = {

  matcher: [

    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',

  ],

};

Auth Helpers

src/lib/auth.ts

import { redirect } from 'next/navigation';

import { createClient } from '@/lib/supabase/server';

export async function getUser() {

  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();

  return user;

}

export async function requireAuth() {

  const user = await getUser();

  if (!user) {

    redirect('/login');

  }

  return user;

}

export async function requireGuest() {

  const user = await getUser();

  if (user) {

    redirect('/dashboard');

  }

}

Auth Pages

src/app/(auth)/login/page.tsx

import { requireGuest } from '@/lib/auth';

import { LoginForm } from '@/components/auth/login-form';

export default async function LoginPage() {

  await requireGuest();

  return (

    <div className="flex min-h-screen items-center justify-center">

      <LoginForm />

    </div>

  );

}

src/components/auth/login-form.tsx

'use client';

import { useState } from 'react';

import { useRouter } from 'next/navigation';

import { createClient } from '@/lib/supabase/client';

export function LoginForm() {

  const [email, setEmail] = useState('');

  const [password, setPassword] = useState('');

  const [error, setError] = useState<string | null>(null);

  const [loading, setLoading] = useState(false);

  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {

    e.preventDefault();

    setLoading(true);

    setError(null);

    const supabase = createClient();

    const { error } = await supabase.auth.signInWithPassword({

      email,

      password,

    });

    if (error) {

      setError(error.message);

      setLoading(false);

      return;

    }

    router.push('/dashboard');

    router.refresh();

  };

  return (

    <form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">

      <div>

        <label htmlFor="email">Email</label>

        <input

          id="email"

          type="email"

          value={email}

          onChange={(e) => setEmail(e.target.value)}

          required

        />

      </div>

      <div>

        <label htmlFor="password">Password</label>

        <input

          id="password"

          type="password"

          value={password}

          onChange={(e) => setPassword(e.target.value)}

          required

        />

      </div>

      {error &#x26;&#x26; <p className="text-red-500">{error}</p>}

      <button type="submit" disabled={loading}>

        {loading ? 'Signing in...' : 'Sign In'}

      </button>

    </form>

  );

}

src/app/(auth)/callback/route.ts

import { createClient } from '@/lib/supabase/server';

import { NextResponse } from 'next/server';

export async function GET(request: Request) {

  const { searchParams, origin } = new URL(request.url);

  const code = searchParams.get('code');

  const next = searchParams.get('next') ?? '/dashboard';

  if (code) {

    const supabase = await createClient();

    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (!error) {

      return NextResponse.redirect(`${origin}${next}`);

    }

  }

  return NextResponse.redirect(`${origin}/login?error=auth_error`);

}

Server Actions

src/app/actions/posts.ts

'use server';

import { revalidatePath } from 'next/cache';

import { redirect } from 'next/navigation';

import { db } from '@/db';

import { posts, NewPost } from '@/db/schema';

import { requireAuth } from '@/lib/auth';

import { eq } from 'drizzle-orm';

export async function createPost(formData: FormData) {

  const user = await requireAuth();

  const title = formData.get('title') as string;

  const content = formData.get('content') as string;

  const [post] = await db.insert(posts).values({

    authorId: user.id,

    title,

    content,

  }).returning();

  revalidatePath('/dashboard');

  redirect(`/posts/${post.id}`);

}

export async function updatePost(id: string, formData: FormData) {

  const user = await requireAuth();

  const title = formData.get('title') as string;

  const content = formData.get('content') as string;

  await db.update(posts)

    .set({ title, content })

    .where(eq(posts.id, id));

  revalidatePath(`/posts/${id}`);

}

export async function deletePost(id: string) {

  const user = await requireAuth();

  await db.delete(posts).where(eq(posts.id, id));

  revalidatePath('/dashboard');

  redirect('/dashboard');

}

Data Fetching

src/db/queries/posts.ts

import { db } from '@/db';

import { posts, profiles } from '@/db/schema';

import { eq, desc, and } from 'drizzle-orm';

export async function getPublishedPosts(limit = 10) {

  return db

    .select({

      id: posts.id,

      title: posts.title,

      content: posts.content,

      author: profiles.name,

      createdAt: posts.createdAt,

    })

    .from(posts)

    .innerJoin(profiles, eq(posts.authorId, profiles.id))

    .where(eq(posts.published, true))

    .orderBy(desc(posts.createdAt))

    .limit(limit);

}

export async function getUserPosts(userId: string) {

  return db

    .select()

    .from(posts)

    .where(eq(posts.authorId, userId))

    .orderBy(desc(posts.createdAt));

}

export async function getPostById(id: string) {

  const [post] = await db

    .select()

    .from(posts)

    .where(eq(posts.id, id))

    .limit(1);

  return post ?? null;

}

In Server Components

// src/app/dashboard/page.tsx

import { requireAuth } from '@/lib/auth';

import { getUserPosts } from '@/db/queries/posts';

export default async function DashboardPage() {

  const user = await requireAuth();

  const posts = await getUserPosts(user.id);

  return (

    <div>

      <h1>Your Posts</h1>

      {posts.map((post) => (

        <article key={post.id}>

          <h2>{post.title}</h2>

          <p>{post.content}</p>

        </article>

      ))}

    </div>

  );

}

Storage

Upload Component

'use client';

import { useState } from 'react';

import { createClient } from '@/lib/supabase/client';

export function AvatarUpload({ userId }: { userId: string }) {

  const [uploading, setUploading] = useState(false);

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {

    const file = e.target.files?.[0];

    if (!file) return;

    setUploading(true);

    const supabase = createClient();

    const fileExt = file.name.split('.').pop();

    const filePath = `${userId}/avatar.${fileExt}`;

    const { error } = await supabase.storage

      .from('avatars')

      .upload(filePath, file, { upsert: true });

    if (error) {

      console.error('Upload error:', error);

    }

    setUploading(false);

  };

  return (

    <input

      type="file"

      accept="image/*"

      onChange={handleUpload}

      disabled={uploading}

    />

  );

}

Get Public URL

import { createClient } from '@/lib/supabase/server';

export async function getAvatarUrl(userId: string) {

  const supabase = await createClient();

  const { data } = supabase.storage

    .from('avatars')

    .getPublicUrl(`${userId}/avatar.png`);

  return data.publicUrl;

}

Realtime

Client Component with Subscription

'use client';

import { useEffect, useState } from 'react';

import { createClient } from '@/lib/supabase/client';

import { Post } from '@/db/schema';

export function RealtimePosts({ initialPosts }: { initialPosts: Post[] }) {

  const [posts, setPosts] = useState(initialPosts);

  useEffect(() => {

    const supabase = createClient();

    const channel = supabase

      .channel('posts')

      .on(

        'postgres_changes',

        { event: '*', schema: 'public', table: 'posts' },

        (payload) => {

          if (payload.eventType === 'INSERT') {

            setPosts((prev) => [payload.new as Post, ...prev]);

          } else if (payload.eventType === 'DELETE') {

            setPosts((prev) => prev.filter((p) => p.id !== payload.old.id));

          } else if (payload.eventType === 'UPDATE') {

            setPosts((prev) =>

              prev.map((p) => (p.id === payload.new.id ? payload.new as Post : p))

            );

          }

        }

      )

      .subscribe();

    return () => {

      supabase.removeChannel(channel);

    };

  }, []);

  return (

    <ul>

      {posts.map((post) => (

        <li key={post.id}>{post.title}</li>

      ))}

    </ul>

  );

}

OAuth Providers

src/components/auth/oauth-buttons.tsx

'use client';

import { createClient } from '@/lib/supabase/client';

export function OAuthButtons() {

  const handleOAuth = async (provider: 'google' | 'github') => {

    const supabase = createClient();

    await supabase.auth.signInWithOAuth({

      provider,

      options: {

        redirectTo: `${window.location.origin}/auth/callback`,

      },

    });

  };

  return (

    <div className="space-y-2">

      <button onClick={() => handleOAuth('google')}>

        Continue with Google

      </button>

      <button onClick={() => handleOAuth('github')}>

        Continue with GitHub

      </button>

    </div>

  );

}

Sign Out

Server Action

// src/app/actions/auth.ts

'use server';

import { redirect } from 'next/navigation';

import { createClient } from '@/lib/supabase/server';

export async function signOut() {

  const supabase = await createClient();

  await supabase.auth.signOut();

  redirect('/login');

}

Sign Out Button

'use client';

import { signOut } from '@/app/actions/auth';

export function SignOutButton() {

  return (

    <form action={signOut}>

      <button type="submit">Sign Out</button>

    </form>

  );

}

Anti-Patterns

  • Using Supabase client for DB queries - Use Drizzle for type-safety
  • Fetching in client components - Prefer server components
  • Not using middleware for auth - Session refresh is critical
  • **Calling cookies() synchronously** - Must await in Next.js 15+
  • Service key in client - Never expose, server-only
  • Missing revalidatePath - Always revalidate after mutations
  • Not handling auth errors - Show user-friendly messages
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card