Connect Next.js with Supabase

Implementation Guide

Overview: Connecting Next.js and Supabase

The Next.js and Supabase integration represents one of the most strategically important full-stack pairings in modern web application development. Next.js, developed by Vercel, is a React-based framework providing server-side rendering, static site generation, edge computing via middleware, and a file-system-based API routing system through either the Pages Router or the newer App Router architecture introduced in version 13. Supabase is an open-source Backend-as-a-Service platform built on top of PostgreSQL that provides a fully managed relational database, auto-generated REST and GraphQL APIs derived from your schema, Row Level Security policies for granular per-row access control enforced at the database layer, real-time data subscriptions over WebSockets using the Realtime API, OAuth-based authentication supporting dozens of providers, and an S3-compatible object storage service. Together, these two platforms compose a complete full-stack development environment with minimal infrastructure management overhead.

The specific architectural challenge this integration addresses is the complex authentication and data access coordination required in applications that serve both server-rendered and client-rendered content. In a Next.js App Router application, data fetching occurs across three distinct execution contexts: Server Components that run exclusively on the server and never expose their logic to the browser, Client Components that execute in the browser and require a public-safe credential, and Route Handlers (or Server Actions) that function as serverless API endpoints. Each of these contexts requires a differently configured Supabase client instance with appropriate credential scoping. Using a single client configuration across all contexts either creates security vulnerabilities (exposing the service role key in the browser) or functionality gaps (failing to read the authenticated user's session in server-rendered components). Getting this configuration correct is the single most common failure point in Next.js-Supabase integrations.

Core Prerequisites

At the Supabase level, you need an active project created at supabase.com. From Project Settings > API, collect three values: the Project URL in the format https://{project_ref}.supabase.co, the anon (public) key which is safe to expose in browser-side code, and the service_role (secret) key which must never appear in client-side code, browser bundle output, or version-controlled files. If the application uses Supabase Auth, configure your OAuth providers under Authentication > Providers and set the Site URL and all permitted Redirect URLs to include the domains of your Next.js application (both local development and production). Row Level Security must be explicitly enabled on every table containing user-specific data—Supabase creates new tables with RLS disabled by default, and forgetting to enable it means all authenticated and unauthenticated users can read and modify all rows without restriction.

At the Next.js level, version 13.4 or higher is required to use the App Router and Server Components architecture. Install the two required packages:

npm install @supabase/supabase-js @supabase/ssr

The @supabase/ssr package is the currently maintained approach for Next.js integration and supersedes the deprecated @supabase/auth-helpers-nextjs package. Configure the following environment variables in .env.local:

NEXT_PUBLIC_SUPABASE_URL=https://{project_ref}.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY={your_anon_key}
SUPABASE_SERVICE_ROLE_KEY={your_service_role_key}

The NEXT_PUBLIC_ prefix causes Next.js to inline these variables into the browser bundle at build time. The SUPABASE_SERVICE_ROLE_KEY must not carry this prefix and should only ever be referenced in server-side execution contexts such as Route Handlers or Server Actions. Committing the service role key to a public repository is a critical security incident that requires immediate key rotation in the Supabase dashboard.

Top Enterprise Use Cases

The primary use case is authenticated server-side data fetching in Next.js Server Components with full Row Level Security enforcement. By creating a Supabase server client that reads the authenticated user's session from the incoming request cookies, Server Components can fetch user-specific data from Supabase before the HTML is rendered, delivering a personalised, SEO-indexable page without any client-side loading state or waterfall data fetching. This pattern is essential for dashboard applications, SaaS portals, and any product where personalised server-rendered content is a requirement.

The second major use case is real-time collaborative features using Supabase's Realtime API in Client Components. Supabase's channel-based subscription system pushes database change events (INSERT, UPDATE, DELETE on specified tables, optionally filtered by column values) to subscribed browser clients over WebSockets. This enables live dashboards, collaborative editing indicators, notification badges, and any UI element that must reflect database state changes in under one second without requiring the client to poll an endpoint.

A third high-value use case is file upload and management through Supabase Storage, orchestrated via Next.js Server Actions. Users submit files through a standard HTML form element in a Client Component. The Server Action on the server side receives the File object from the FormData, uploads it to the appropriate Supabase Storage bucket using a server-side Supabase client authenticated with the user's session, and inserts a metadata row into a database table with the resulting storage path and public URL. This pattern keeps the upload logic entirely on the server, prevents direct client-to-storage uploads that could bypass access policies, and provides a transactional link between the stored file and its database record.

Step-by-Step Implementation Guide

Setting up the Supabase client correctly for the Next.js App Router requires two separate client utility files because the server-side and browser-side execution contexts have fundamentally different mechanisms for accessing and persisting session state. The server client reads and writes cookies via the Next.js cookies() API to maintain the user's session across requests, while the browser client uses the Supabase JS library's built-in localStorage persistence.

Create lib/supabase/server.ts for use in Server Components, Route Handlers, and Server Actions:

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 {
            // setAll called from a Server Component where cookies are read-only
          }
        },
      },
    }
  );
}

Create lib/supabase/client.ts for use in Client Components:

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

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

A middleware.ts file at the project root is required to refresh the authenticated user's session on every server request, preventing the access token from expiring between navigations. Without this middleware, users will encounter unexpected JWT expired errors mid-session:

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() {
          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)
          );
        },
      },
    }
  );

  await supabase.auth.getUser();
  return supabaseResponse;
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

For a data mutation example, a Server Action that inserts a user-owned row with RLS enforcement is defined with the "use server" directive at the top of the file. The server-side Supabase client authenticates the operation using the current user's session cookies, meaning the RLS policy auth.uid() = user_id will correctly allow or deny the INSERT based on the row being inserted:

"use server";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error("Unauthenticated");

  const { data, error } = await supabase
    .from("posts")
    .insert({
      title: formData.get("title") as string,
      body: formData.get("body") as string,
      user_id: user.id,
    })
    .select()
    .single();

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

  revalidatePath("/posts");
  return data;
}

For a real-time subscription in a Client Component, the Supabase channel must be initialised inside a useEffect hook and cleaned up on component unmount to prevent stale WebSocket connections and duplicate event handlers. The subscription filters on a specific table and, optionally, on a column value to receive only events relevant to the current user:

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

export default function LiveFeed({ userId }: { userId: string }) {
  const [posts, setPosts] = useState<Post[]>([]);
  const supabase = createClient();

  useEffect(() => {
    const channel = supabase
      .channel("realtime-posts")
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "posts", filter: `user_id=eq.${userId}` },
        (payload) => {
          setPosts((prev) => [payload.new as Post, ...prev]);
        }
      )
      .subscribe();

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

  return <div>{posts.map((p) => <p key={p.id}>{p.title}</p>)}</div>;
}

Common Pitfalls & Troubleshooting

The most common failure mode is RLS policy misconfiguration, which manifests as empty query results rather than an explicit error when using the anon key. Supabase intentionally returns an empty array for unauthorised SELECT operations to prevent information leakage—this makes RLS bugs difficult to distinguish from queries that legitimately return no rows. To diagnose, test the same query in the Supabase SQL Editor while impersonating the relevant role using SET ROLE authenticated and SET request.jwt.claims = '{"sub": "{user_uuid}"}'. Ensure your SELECT policies include a USING clause, your INSERT policies include a WITH CHECK clause, and both reference auth.uid() correctly.

A JWT expired error returned in the Supabase client response body means the middleware session refresh is not running for the affected route. Check the matcher pattern in middleware.ts and ensure it does not accidentally exclude the route in question. A common mistake is using overly specific matchers that only cover certain path prefixes, leaving other routes without session refresh coverage.

A new row violates row-level security policy for table "{tableName}" error during INSERT or UPDATE means the WITH CHECK clause evaluates to false for the row being inserted. This most commonly occurs when the user_id column is not explicitly set in the insert payload, causing auth.uid() = user_id to compare against null. Always explicitly set all columns that RLS policies reference in your mutation payloads.

Connection pool exhaustion errors logged as too many connections in the Supabase dashboard indicate the application is creating an excessive number of direct PostgreSQL connections. This is most often caused by instantiating the Supabase client at module scope in a serverless function—each cold start creates a new client and therefore a new connection that persists until the function container is recycled. Always instantiate the Supabase client inside the request handler function body, not at the module top level. For applications with very high query concurrency, use Supabase's built-in connection pooler (PgBouncer) by appending ?pgbouncer=true to the database connection string in the Supabase project settings.