Connect Supabase with Next.js

Implementation Guide

Overview: Connecting Supabase and Next.js

Supabase and Next.js are two of the most complementary technologies in the modern full-stack JavaScript ecosystem. Supabase provides a hosted PostgreSQL database with a RESTful API (PostgREST), a Realtime WebSocket server, an S3-compatible storage layer, and a GoTrue-based authentication service, all exposed through the @supabase/supabase-js client SDK. Next.js, maintained by Vercel, offers a hybrid rendering framework that supports Static Site Generation (SSG), Server-Side Rendering (SSR), Incremental Static Regeneration (ISR), and React Server Components (RSC) as of the App Router introduced in Next.js 13. The integration between these two platforms is not merely a REST API call — it is an architectural decision that determines how data flows from your PostgreSQL backend into the rendering pipeline of your frontend application, and how user authentication state is propagated across both server and client boundaries.

For enterprise teams, the Supabase-to-Next.js integration addresses a very specific class of problems: stale data in statically generated pages, session hydration mismatches between server and client components, and the challenge of securing server-side data fetching with row-level security (RLS) policies that respect the authenticated user's JWT. This guide covers the full architecture required to solve all three of these problems in a production environment using the latest @supabase/ssr package, which replaced the deprecated @supabase/auth-helpers-nextjs package.

Core Prerequisites

Before implementing this integration, your environment must satisfy several hard requirements. On the Supabase side, you need a project with at minimum the following configuration: the project's anon (public) key and the service_role (secret) key, both available under Project Settings > API. You must never expose the service_role key to the browser. For RLS to function correctly across server components, you need the auth.uid() function to resolve correctly from the JWT passed in the Authorization header, which means your Supabase project must have RLS enabled on all tables that hold user-scoped data. The required OAuth 2.0 scopes for Supabase's GoTrue authentication (e.g., when using a third-party provider like GitHub or Google) include email and profile at minimum; additional provider-specific scopes like repo for GitHub must be configured in the Supabase Dashboard under Authentication > Providers.

On the Next.js side, you must be running Next.js 13.4 or later to use the App Router with full Server Component support. Node.js 18.17 or later is required. The following npm packages must be installed: @supabase/supabase-js (v2.x), @supabase/ssr (v0.x), and server-only (to enforce server-component-only imports). Your .env.local file must define NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY. Admin-level access to the Next.js deployment platform (Vercel or self-hosted) is required to set these environment variables in production.

Top Enterprise Use Cases

The most critical enterprise use case is authenticated data fetching inside React Server Components with RLS enforcement. In a multi-tenant SaaS application, each user should only see their own data. By passing the user's session token to the Supabase client on the server, PostgREST evaluates the JWT against your RLS policies, and only returns rows where auth.uid() matches the user_id column — without any application-level filtering code.

The second major use case is cache invalidation with Incremental Static Regeneration. When a record in Supabase is updated via a database trigger or an API mutation, Next.js must be told to purge the stale static page and regenerate it. This requires calling the revalidatePath() or revalidateTag() functions from a Next.js Route Handler, triggered by a Supabase Database Webhook.

The third use case is real-time collaborative UIs. Supabase's Realtime server publishes PostgreSQL change events (INSERT, UPDATE, DELETE) over WebSockets using the Postgres Logical Replication mechanism. Client components in Next.js can subscribe to these channels to update local state without polling.

Step-by-Step Implementation Guide

The implementation begins with creating the Supabase client utilities. The @supabase/ssr package requires you to create two distinct client factory functions: one for Server Components and one for Client Components. The server client reads and writes cookies via the cookies() function from next/headers, while the client factory uses createBrowserClient.

For the server-side client, create a file at utils/supabase/server.ts. The implementation uses the createServerClient function and passes cookie handlers that Next.js requires for session management across the App Router middleware chain:

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export function createClient() {
  const cookieStore = 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 {}
        },
      },
    }
  );
}

The middleware configuration is equally critical. Without a middleware.ts at the project root, the session cookie is never refreshed, and server components will always see an unauthenticated user after the initial JWT expires. The middleware must call supabase.auth.getUser() on every request to trigger a silent token refresh if the access token is expired but the refresh token is still valid. The middleware should be configured to match all routes except static assets and the Next.js _next internals, using a matcher pattern like /((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*).

For the ISR cache invalidation webhook, create a Route Handler at app/api/revalidate/route.ts. This handler receives a POST request from a Supabase Database Webhook, validates a shared secret (passed as a custom x-webhook-secret header), and calls revalidatePath or revalidateTag. The incoming Supabase Database Webhook payload for a table called posts on an UPDATE event will have the following structure:

{
  "type": "UPDATE",
  "table": "posts",
  "schema": "public",
  "record": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "Updated Post Title",
    "slug": "updated-post-title",
    "updated_at": "2024-11-01T12:00:00.000Z"
  },
  "old_record": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "Original Post Title",
    "slug": "original-post-title",
    "updated_at": "2024-10-28T09:00:00.000Z"
  }
}

Your Route Handler extracts record.slug from this payload and calls revalidatePath(/blog/${record.slug}) to purge only the specific page that changed, minimizing unnecessary regeneration across your deployment.

For Realtime subscriptions in a Client Component, import createBrowserClient from @supabase/ssr inside a useEffect hook. Subscribe to the channel using the postgres_changes event type, specifying schema: 'public', table: 'posts', and event: '*' to catch all change types. Always return a cleanup function that calls supabase.removeChannel(channel) to prevent WebSocket connection leaks.

Common Pitfalls & Troubleshooting

A 401 Unauthorized response from the Supabase REST API inside a Server Component almost always means one of two things: the session cookie was not correctly passed to the server client, or the RLS policy is rejecting the authenticated user. Verify by temporarily using the service_role key to bypass RLS entirely and confirm the query works — if it does, the issue is in your RLS policy logic, not the network layer.

A 406 Not Acceptable from PostgREST indicates a content negotiation failure. This happens when the response shape does not match the client's expected format, often caused by calling .single() on a query that returns zero or multiple rows. Use .maybeSingle() for queries that may return null.

A 429 Too Many Requests from Supabase's Auth API is common during development when hot-reloading triggers repeated getUser() calls. In production, implement request deduplication by caching the user object in a React cache using the cache() function from React, scoped to the current request lifecycle.

If ISR revalidation webhooks are not firing, check that the Supabase Database Webhook has the correct HTTP endpoint (your production or tunnel URL), that the table change event type (INSERT/UPDATE/DELETE) is enabled, and that the x-webhook-secret header value matches your REVALIDATION_SECRET environment variable exactly, including case sensitivity.