# Building a Full-Stack App With Next.js 16 and Supabase in 2026

By [Eoin Villarin](https://amvprosox.com/about). Published 2025-11-28, updated 2026-10-28. Canonical: https://amvprosox.com/blog/full-stack-app-nextjs-supabase-2025

> Next.js 16 and Supabase cover auth, Postgres, storage and realtime without servers to babysit. Learn the current proxy.ts and getClaims setup, Row Level Security, Realtime and deployment.

## How do I build a full-stack app with Next.js and Supabase in 2026?

Building with Next.js and Supabase in 2026 means following current patterns: install @supabase/ssr, use publishable and secret API keys instead of the legacy anon and service_role keys, refresh sessions in proxy.ts, protect pages with getClaims() rather than getSession(), enable Row Level Security on every table and recheck auth inside every Server Action.

**Key takeaways**

- Supabase is deprecating the legacy anon and service_role keys by the end of 2026 in favour of publishable and secret keys.
- getClaims() validates the JWT signature on every call, while getSession() only reads whatever is in the cookie.
- Wrapping auth.uid() in a select lets Postgres evaluate it once per query instead of once per row.
- Supabase Realtime docs recommend Broadcast over Postgres Changes beyond about 3,000 concurrent subscribers on the same changes.
- The Supabase free plan includes a 500 MB database and 50,000 monthly active users, and Pro starts at $25 monthly.

Pairing **Next.js and Supabase** is still one of the fastest ways to ship a real full-stack product without running your own servers. You get React with server rendering on one side and a managed Postgres database with authentication, file storage and realtime on the other. The catch is that both moved quickly over the last year, and most tutorials online still use patterns that are now deprecated.

Next.js 16 renamed `middleware.ts` to `proxy.ts`, Supabase introduced publishable and secret API keys, and the recommended way to check a user on the server is now `getClaims()`. If you copy a 2024 tutorial today you will hit warnings at best and subtle auth bugs at worst.

This guide walks through the current setup as of October 2026, with Next.js 16.4 and the `@supabase/ssr` package. You will learn how to wire up auth, protect routes, query Postgres safely with Row Level Security, add realtime updates, handle file uploads and deploy.

## Why do Next.js and Supabase work so well together?

Each tool covers the part of the stack the other one leaves out. Next.js handles routing, rendering, caching and server-side logic through Server Components and Server Actions. Supabase gives you a hosted Postgres database plus the services you would otherwise wire up yourself.

![Laptop showing code on a bright desk, building the app layer of a full stack project](https://amvprosox.com/api/img/1793146715037-7yf7e6)
*Next.js renders the app while Supabase handles data, auth and files. Photo: rawpixel, CC0*

| Need | Supabase feature | Where it runs in Next.js |
| --- | --- | --- |
| Sign up, login, social auth | Supabase Auth | Server Actions, Route Handlers, proxy.ts |
| Relational data | Postgres with Row Level Security | Server Components and Server Actions |
| Live updates | Realtime (Postgres Changes, Broadcast) | Client Components |
| Images and documents | Storage buckets with policies | Client uploads or Server Actions |
| Background or custom logic | Edge Functions and Postgres functions | Called via supabase.functions or rpc() |

Because Supabase is plain Postgres underneath, you are not locked into a proprietary data model. You can export your data, connect any SQL client and write migrations by hand when you need to. If the server and client split in React is still new to you, read my guide to [React Server Components](https://amvprosox.com/blog/react-19-server-components-complete-guide) first.

## How to set up a Next.js 16 project with Supabase

Start with a fresh App Router project and install the two Supabase packages. Next.js 16 requires Node.js 20.9 or later.

```
npx create-next-app@latest my-app --typescript --app
cd my-app
npm install @supabase/supabase-js @supabase/ssr
```

Create a project in the Supabase dashboard, then copy the project URL and the **publishable key** into `.env.local`:

```
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
```

Supabase is deprecating the legacy `anon` and `service_role` keys by the end of 2026, according to its [API keys documentation](https://supabase.com/docs/guides/api/api-keys). If a tutorial tells you to copy a long key that starts with `eyJ`, it was written for the old system. The secret key bypasses Row Level Security, so it must never appear in client code or in any variable prefixed with `NEXT_PUBLIC_`.

### Create the browser and server clients

You need two small factory functions: one for Client Components and one for everything that runs on the server. Put them in `lib/supabase/`.

```
// 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_PUBLISHABLE_KEY!
  )
}

// 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_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component. Safe to ignore
            // because proxy.ts refreshes the session.
          }
        },
      },
    }
  )
}
```

Create a new server client on every request. Never store it in a module-level variable, because on serverless platforms that can leak one user's session into another request.

## How do you protect routes with proxy.ts and getClaims?

Server Components cannot write cookies, so something has to refresh expired auth tokens before your pages render. In Next.js 16 that job belongs to `proxy.ts`, which replaced `middleware.ts` and now runs on the Node.js runtime by default. The [Next.js proxy documentation](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) includes a codemod if you are migrating an older project.

![Bolt lock on a blue wooden door, one of several layers of protection](https://amvprosox.com/api/img/1793146716246-0jf337)
*Defence in depth: proxy, server checks and Row Level Security. Photo: rawpixel, CC0*

```
// lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let response = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          response = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
          Object.entries(headers).forEach(([key, value]) =>
            response.headers.set(key, value)
          )
        },
      },
    }
  )

  // Do not run code between createServerClient and getClaims
  const { data } = await supabase.auth.getClaims()

  if (!data?.claims && request.nextUrl.pathname.startsWith('/dashboard')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return response
}

// proxy.ts (project root)
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'

export async function proxy(request: NextRequest) {
  return updateSession(request)
}

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

### Why getClaims and not getSession?

The Supabase [server-side auth guide for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs) is explicit: always use `getClaims()` to protect pages and data, and never trust `getSession()` in server code. `getClaims()` validates the JWT signature against your project's published keys every time, while `getSession()` simply reads whatever is in the cookie.

### Proxy is not your only security layer

A redirect in `proxy.ts` improves the user experience, but it is not authorisation. Server Actions are handled as POST requests to the page that uses them, so a matcher change can silently skip them. Check the user again inside every Server Action and Route Handler, and let Row Level Security enforce access in the database itself.

## Querying Postgres from Server Components with Row Level Security

Row Level Security (RLS) is what makes it safe to query Supabase directly from your app. Policies run inside Postgres, so even a bug in your Next.js code cannot return another user's rows. Here is a simple `projects` table where every user only sees their own data:

```
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null default auth.uid() references auth.users (id),
  name text not null,
  created_at timestamptz not null default now()
);

create index on public.projects (owner_id);
alter table public.projects enable row level security;

create policy "Owners read their projects"
on public.projects for select to authenticated
using ((select auth.uid()) = owner_id);

create policy "Owners create their projects"
on public.projects for insert to authenticated
with check ((select auth.uid()) = owner_id);
```

Wrapping `auth.uid()` in a `select` lets Postgres evaluate it once per query instead of once per row, and the index keeps policy checks fast as the table grows. Now the Server Component reads data with no API route in between:

```
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'

export default async function DashboardPage() {
  const supabase = await createClient()
  const { data: projects, error } = await supabase
    .from('projects')
    .select('id, name, created_at')
    .order('created_at', { ascending: false })

  if (error) throw error

  return (

      {projects.map((project) => (

- {project.name}
      ))}

  )
}
```

### Generate TypeScript types from your schema

Run `npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > database.types.ts` and pass the generated `Database` type to `createServerClient` and `createBrowserClient`. Every `select`, `insert` and filter is then type checked against your real columns. For validating form input on top of that, see my guide to [type-safe validation with Zod](https://amvprosox.com/blog/typescript-zod-type-safe-api-validation).

### Write data with Server Actions

```
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'

export async function createProject(formData: FormData) {
  const supabase = await createClient()
  const { data } = await supabase.auth.getClaims()
  if (!data?.claims) throw new Error('Not signed in')

  const name = String(formData.get('name') ?? '').trim()
  if (!name) return

  const { error } = await supabase.from('projects').insert({ name })
  if (error) throw error

  revalidatePath('/dashboard')
}
```

The `owner_id` column defaults to `auth.uid()`, and the insert policy rejects any row that tries to claim someone else's ID.

## Adding real-time features with Supabase Realtime

Realtime streams database changes to connected browsers over WebSockets. First add the table to the `supabase_realtime` publication with `alter publication supabase_realtime add table public.projects;`. Then subscribe from a Client Component:

![Taxi light trails on city streets from above, like realtime updates flowing to browsers](https://amvprosox.com/api/img/1793146717536-6m1wej)
*Realtime pushes inserts and updates straight to the browser. Photo: rawpixel, CC0*

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

type Project = { id: string; name: string; created_at: string }

export function LiveProjects({ initial }: { initial: Project[] }) {
  const [projects, setProjects] = useState(initial)

  useEffect(() => {
    const supabase = createClient()
    const channel = supabase
      .channel('projects-feed')
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'projects' },
        (payload) => setProjects((prev) => [payload.new as Project, ...prev])
      )
      .subscribe()

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

  return {projects.map((p) =>
- {p.name})}

}
```

Render the initial list in a Server Component and pass it in, so the page is useful before the socket connects. RLS policies apply to Postgres Changes, so each user only receives rows they are allowed to read.

> Postgres Changes checks authorisation once per subscriber for every change. That is perfect for dashboards and team tools, but for very large audiences you should switch to Broadcast.

The [Realtime documentation](https://supabase.com/docs/guides/realtime/postgres-changes) recommends Broadcast once you expect more than about 3,000 concurrent subscribers on the same changes, because it sends each change once and fans it out.

## File uploads with Supabase Storage

Storage buckets use the same policy model as your tables. A common pattern is to store each user's files in a folder named after their user ID, then write a policy on `storage.objects` that checks `(storage.foldername(name))[1] = (select auth.uid())::text`. Uploading from the browser is then one call:

```
const { error } = await supabase.storage
  .from('avatars')
  .upload(`${userId}/avatar.webp`, file, { upsert: true })
```

Keep buckets private unless the files are genuinely public, and use signed URLs for anything a user should only see while logged in.

## Deploying your app and what it costs

Vercel remains the simplest home for a Next.js and Supabase app, and since Next.js 16.2 the stable Adapter API makes other platforms more consistent too. Before launch, work through this checklist:

- Add your production URL and preview URLs to the allowed redirect list in Supabase Auth settings.

- Set up a custom SMTP provider for auth emails, since the built-in sender is meant for testing.

- Confirm RLS is enabled on every table in the `public` schema using the Security Advisor.

- Keep `SUPABASE_SECRET_KEY` only in server environment variables.

- Turn on point-in-time recovery or regular backups once real customer data arrives.

On cost, the Supabase free plan currently includes a 500 MB database, 50,000 monthly active users, 1 GB of file storage and 200 concurrent realtime connections, but free projects pause after a week of inactivity. The Pro plan starts at $25 per month. Check the [Supabase pricing page](https://supabase.com/pricing) for current limits before you commit.

If you are planning wider upgrades at the same time, my roundup of [what is new in Next.js and React in 2026](https://amvprosox.com/blog/nextjs-react-whats-new-2026) covers Cache Components and the other features worth adopting.

## Frequently asked questions

### Is Supabase a good backend for Next.js in 2026?

Yes, for most SaaS products, dashboards, marketplaces and internal tools. You get Postgres, auth, storage and realtime with official Next.js support through `@supabase/ssr`. It is less suited to workloads that need a custom database engine or heavy long-running background processing, where a dedicated backend service may be a better fit.

### Should I use middleware.ts or proxy.ts with Supabase?

Use `proxy.ts` on Next.js 16 and later. The middleware file convention is deprecated and was renamed to proxy, with an official codemod to migrate. The Supabase session refresh code inside is the same either way, so on Next.js 15 you can keep `middleware.ts` until you upgrade.

### Do I still need API routes with Supabase?

Often not. Server Components can query Supabase directly, and Server Actions handle most writes. You still want Route Handlers for webhooks from services like Stripe, for endpoints that mobile apps or third parties call, and for any operation that needs the secret key, which must stay on the server.

### How do I stop users seeing each other's data?

Enable Row Level Security on every table and write policies that compare `auth.uid()` with an owner or membership column. RLS runs inside Postgres, so it protects you even if application code has a bug. Test policies by signing in as two different users and confirming each one only sees their own rows.

## Next steps: build it or get help

Next.js and Supabase give a small team the reach of a much larger one, as long as you follow the current patterns: publishable keys, `proxy.ts` with `getClaims()`, RLS on every table and auth checks in every Server Action. Start with one table and one protected page, then layer on realtime and storage once the basics are solid.

If you need a custom web app built or an existing one modernised, [send me the details through my contact form](https://amvprosox.com/#contact-form). You can also explore my [case studies](https://amvprosox.com/case-studies), including the [Musix cross-platform app](https://amvprosox.com/case-studies/musix), to see how I approach full-stack projects.
