DEV Community

Gaurav Guha
Gaurav Guha

Posted on Originally published at thefrontkit.com

Wire Supabase Auth and Database into Next.js App Router

The Problem

Wiring Supabase into a Next.js App Router project looks simple until server components return stale sessions, middleware breaks auth refresh, or row-level security gets skipped entirely. This guide covers the full integration with the gotchas that actually bite.

Create the Supabase Project

Sign up at supabase.com and create a project in a region near your users. The free tier gives 500 MB database, 1 GB storage, 2 GB egress, and 50,000 monthly active auth users.

From the API settings grab three values:

  • SUPABASE_URL: the project's API endpoint
  • SUPABASE_ANON_KEY: public client-side key (safe to ship)
  • SUPABASE_SERVICE_ROLE_KEY: server-side admin key (never expose to the browser)

Install SDK and Configure Environment

npm install @supabase/supabase-js @supabase/ssr
Enter fullscreen mode Exit fullscreen mode

Add to .env.local:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...your-anon-key
SUPABASE_SERVICE_ROLE_KEY=eyJ...your-service-role-key
Enter fullscreen mode Exit fullscreen mode

The NEXT_PUBLIC_* prefix exposes variables to the browser. The service role key has no prefix because it must stay server-side.

Create Three Supabase Clients

Next.js 16 with the App Router needs a server client, a browser client, and a middleware client.

lib/supabase/server.ts:

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: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => {
            cookieStore.set(name, value, options);
          });
        },
      },
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

lib/supabase/client.ts:

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

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}
Enter fullscreen mode Exit fullscreen mode

middleware.ts at project root:

import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  let response = 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: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => {
            response.cookies.set(name, value, options);
          });
        },
      },
    }
  );
  await supabase.auth.getUser();
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Enter fullscreen mode Exit fullscreen mode

The middleware refreshes the auth session on every request. Without it, server components see stale sessions.

Wire Auth Into the Template

Replace template auth handlers with Supabase calls.

Login handler:

'use server';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';

export async function login(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  });
  if (error) return { error: error.message };
  redirect('/dashboard');
}
Enter fullscreen mode Exit fullscreen mode

Signup handler:

export async function signup(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({
    email: formData.get('email') as string,
    password: formData.get('password') as string,
    options: { emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
  });
  if (error) return { error: error.message };
  redirect('/auth/check-email');
}
Enter fullscreen mode Exit fullscreen mode

OAuth (Google example):

export async function signInWithGoogle() {
  const supabase = await createClient();
  const { data } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
  });
  if (data.url) redirect(data.url);
}
Enter fullscreen mode Exit fullscreen mode

Callback route at 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');
  if (code) {
    const supabase = await createClient();
    await supabase.auth.exchangeCodeForSession(code);
  }
  return NextResponse.redirect(`${origin}/dashboard`);
}
Enter fullscreen mode Exit fullscreen mode

Enable OAuth providers in the Supabase dashboard under Authentication > Providers.

Query Database from Server Components

Server components run on the server, so database calls happen inside the Vercel function: no API route needed for read-only views.

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

export default async function ProjectsPage() {
  const supabase = await createClient();
  const { data: projects, error } = await supabase
    .from('projects')
    .select('*, tasks(count)')
    .order('created_at', { ascending: false });

  if (error) throw new Error(error.message);

  return (
    <div>
      {projects.map((p) => (
        <div key={p.id}>{p.name}</div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Enable Row-Level Security

This is the step most teams skip. Without RLS, your anon key gives the browser unrestricted access to every table.

For a projects table where each project belongs to a user:

-- Enable RLS on the table
alter table projects enable row level security;

-- Allow users to select only their own projects
create policy "Users can view their own projects"
  on projects for select
  using ( auth.uid() = user_id );

-- Allow users to insert projects with their own user_id
create policy "Users can insert their own projects"
  on projects for insert
  with check ( auth.uid() = user_id );

-- Allow users to update their own projects
create policy "Users can update their own projects"
  on projects for update
  using ( auth.uid() = user_id );

-- Allow users to delete their own projects
create policy "Users can delete their own projects"
  on projects for delete
  using ( auth.uid() = user_id );
Enter fullscreen mode Exit fullscreen mode

Run this in the Supabase SQL editor for every table with user-specific data. Test by hitting the API with the anon key from a logged-out browser tab: every query should return empty.

File Uploads via Supabase Storage

Create a storage bucket in the dashboard. Set to "private" for user-uploaded files (avatars, attachments).

Upload from a client component:

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

async function uploadAvatar(file: File, userId: string) {
  const supabase = createClient();
  const fileName = `${userId}/${Date.now()}-${file.name}`;
  const { data, error } = await supabase.storage
    .from('avatars')
    .upload(fileName, file, { upsert: true });
  if (error) throw error;
  return data.path;
}
Enter fullscreen mode Exit fullscreen mode

Get a signed URL for displaying a private file:

const { data } = await supabase.storage
  .from('avatars')
  .createSignedUrl(filePath, 3600); // 1 hour expiry
Enter fullscreen mode Exit fullscreen mode

Set storage policies the same way as table policies: every bucket should have RLS scoping access to the owning user.

Realtime Subscriptions (Optional)

For live features: kanban boards, chat, presence: Supabase realtime is the fastest path.

'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';

export function LiveTasks({ projectId }: { projectId: string }) {
  const [tasks, setTasks] = useState([]);
  const supabase = createClient();

  useEffect(() => {
    const channel = supabase
      .channel(`tasks:${projectId}`)
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` },
        (payload) => {
          // Handle insert, update, delete
          console.log(payload);
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [projectId]);

  return <div>...</div>;
}
Enter fullscreen mode Exit fullscreen mode

Realtime requires enabling replication on the table in the Supabase dashboard. Without it, the subscription succeeds but no events fire.

Common Gotchas

Forgetting RLS. The biggest security risk. Default-deny by enabling RLS, then add specific allow policies. Never ship with RLS off.

Server vs browser client confusion. The server client uses cookies for session; the browser client uses in-memory session. Mixing them causes "user is null" bugs that look like broken auth.

Skipping the middleware. Without auth-refreshing middleware, server components see stale or expired sessions. Symptoms: random logouts, "user is null" after navigation.

Service role key in client code. The service role key bypasses RLS. It must only run on the server. Putting it in a NEXT_PUBLIC_ variable or referencing it from a client component is a critical security incident.

Forgetting emailRedirectTo for signups. Without it, Supabase redirects to its own confirmation page, which doesn't carry the session into your app. Always specify the redirect.

Real-time without replication enabled. Subscribing to changes on a table without replication means silent failure. Enable replication in the dashboard for every table you subscribe to.

Free tier limits. 500 MB database, 1 GB storage, 2 GB egress. Easy to hit if you store user-uploaded files without CDN caching. Plan for Pro tier ($25/month) once you have meaningful users.


This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.

Top comments (0)