Type something to search...
Building a Full-Stack App with Next.js and Supabase

Building a Full-Stack App with Next.js and Supabase

Supabase gives you a Postgres database, authentication, file storage, and realtime subscriptions behind one project and one client library. Next.js gives you server rendering, Server Actions, and a routing layer. Put them together and you can build a real full-stack app without writing a separate backend.

The part that trips people up is the seam between the two: auth sessions live in cookies, Server Components can read cookies but can't write them, and the database enforces access with row level security rather than code in your API. Once you understand how those pieces fit, the rest is straightforward.

In this post I'll build a small notes app from scratch: a notes table protected by row level security, email and password sign-in, a Proxy that keeps sessions fresh, a Server Component that lists notes, Server Actions that create and delete them, generated TypeScript types, and a realtime feed.

What You'll Build

The finished app has:

  • /login with sign-in and sign-up forms
  • /notes, a protected page listing the signed-in user's notes
  • A form to add notes and a button to delete them
  • Live updates when notes change in another tab

The project structure:

src/
  app/
    login/
      page.tsx
      actions.ts
    auth/confirm/route.ts
    notes/
      page.tsx
      actions.ts
      live-notes.tsx
  utils/supabase/
    server.ts
    client.ts
  types/database.types.ts
proxy.ts

Setting Up the Supabase Project

Create a project in the Supabase dashboard, then open the SQL editor and run:

create table public.notes (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid()
    references auth.users (id) on delete cascade,
  content text not null check (char_length(content) between 1 and 500),
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;

create policy "Users can read their own notes"
  on public.notes for select
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "Users can insert their own notes"
  on public.notes for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

create policy "Users can delete their own notes"
  on public.notes for delete
  to authenticated
  using ((select auth.uid()) = user_id);

create index notes_user_id_idx on public.notes (user_id);

This is the most important part of the whole app, so it's worth reading carefully:

  • user_id defaults to auth.uid(), the ID of the user making the request. Your app never has to send it, which also means it can't send the wrong one.
  • enable row level security makes the table deny everything by default. Without policies, nobody can read or write it through the API.
  • Each policy grants one operation to signed-in users, but only on rows where user_id matches their own ID. using filters which existing rows are visible; with check validates new rows.
  • Wrapping auth.uid() in (select ...) lets Postgres evaluate it once per query rather than once per row, which Supabase recommends for performance.
  • The index on user_id keeps those policy filters fast as the table grows.

With RLS in place, the browser and server both use the same public key, and the database decides what each user can see. Your Next.js code adds convenience, not security.

Installing the Client Libraries

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

@supabase/supabase-js is the main client. @supabase/ssr wraps it with helpers that store the auth session in cookies instead of localStorage, which is what lets Server Components know who's signed in.

Copy the project URL and publishable key from the dashboard's API settings into .env.local:

# .env.local
NEXT_PUBLIC_SUPABASE_URL="https://your-project-ref.supabase.co"
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY="sb_publishable_..."

Both are safe to expose to the browser; RLS is what protects your data. Older projects show an "anon" key instead of a publishable key, and it works the same way here. The service_role or secret key bypasses RLS entirely, so it must never have a NEXT_PUBLIC_ prefix and shouldn't be needed for this app at all.

Creating Supabase Clients for Server and Browser

You need two small factories: one for server code and one for Client Components.

// src/utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/types/database.types";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient<Database>(
    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, where cookies are read-only.
            // Safe to ignore because proxy.ts refreshes the session.
          }
        },
      },
    },
  );
}

cookies() is async in Next.js 16, so the factory is async too. The try/catch exists because Supabase may try to write refreshed tokens, and writing cookies is only allowed in Server Actions, Route Handlers, and Proxy. In a Server Component the write throws, which is fine because Proxy handles the refresh before the page renders.

Create a new server client per request (calling createClient() inside your component or action), never a shared module-level instance. Each request has its own cookies.

// src/utils/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/types/database.types";

export function createClient() {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
  );
}

The browser client reads the same auth cookies, so a user signed in on the server is signed in on the client too.

Refreshing Sessions in Proxy

Supabase access tokens are short-lived. Something has to refresh them and write the new cookies before Server Components read them. In Next.js 16 that something is proxy.ts (the convention that replaced middleware.ts).

// proxy.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function proxy(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() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  // Refreshes the session if needed. Don't put code between
  // createServerClient and this call.
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user && request.nextUrl.pathname.startsWith("/notes")) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return response;
}

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

What's happening:

  • getAll reads cookies from the incoming request.
  • When Supabase refreshes the token, setAll writes new cookies in two places: onto the request (so Server Components rendering this request see the fresh session) and onto the response (so the browser stores it).
  • supabase.auth.getUser() triggers the refresh and validates the token with the Supabase Auth server.
  • The redirect is an optimistic convenience for signed-out visitors. It is not your security boundary: RLS is, and you'll also check the user again on the page and in actions.
  • The matcher skips static assets and images so Proxy doesn't run for every file request.

Adding Sign-Up and Sign-In

Server Actions handle the auth forms. Because they run on the server and are allowed to write cookies, the session cookie is set as part of the action's response.

// src/app/login/actions.ts
"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";

export async function login(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: String(formData.get("email")),
    password: String(formData.get("password")),
  });

  if (error) redirect("/login?error=invalid-credentials");

  redirect("/notes");
}

export async function signup(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signUp({
    email: String(formData.get("email")),
    password: String(formData.get("password")),
  });

  if (error) redirect("/login?error=signup-failed");

  redirect("/login?message=check-your-email");
}

export async function logout() {
  const supabase = await createClient();
  await supabase.auth.signOut();
  redirect("/login");
}

The login page wires both actions to one form using formAction on each button:

// src/app/login/page.tsx
import { login, signup } from "./actions";

export default async function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ error?: string; message?: string }>;
}) {
  const { error, message } = await searchParams;

  return (
    <main>
      <h1>Sign in</h1>
      {error && <p role="alert">Something went wrong: {error}</p>}
      {message && <p>Check your inbox to confirm your email.</p>}
      <form>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" required />
        <button formAction={login}>Log in</button>
        <button formAction={signup}>Sign up</button>
      </form>
    </main>
  );
}

searchParams is a promise in Next.js 16 and must be awaited.

Confirming Email Addresses

By default Supabase sends a confirmation email on sign-up. Update the "Confirm signup" email template in the dashboard so its link points at your app, for example {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email, then add a Route Handler that verifies it:

// src/app/auth/confirm/route.ts
import type { EmailOtpType } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import type { NextRequest } from "next/server";
import { createClient } from "@/utils/supabase/server";

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const tokenHash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType | null;

  if (tokenHash && type) {
    const supabase = await createClient();
    const { error } = await supabase.auth.verifyOtp({
      type,
      token_hash: tokenHash,
    });
    if (!error) redirect("/notes");
  }

  redirect("/login?error=confirmation-failed");
}

verifyOtp exchanges the token for a session and the server client stores it in cookies. Route Handlers can set cookies, so no extra work is needed.

Reading Notes in a Server Component

The notes page creates a server client, checks the user, and queries the table. RLS ensures only that user's rows come back, even though the query has no where clause.

// src/app/notes/page.tsx
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { addNote, deleteNote } from "./actions";
import { logout } from "../login/actions";
import { LiveNotes } from "./live-notes";

async function NotesList() {
  const supabase = await createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: notes, error } = await supabase
    .from("notes")
    .select("id, content, created_at")
    .order("created_at", { ascending: false });

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

  return (
    <>
      <p>Signed in as {user.email}</p>
      <LiveNotes userId={user.id} />
      <ul>
        {notes.map((note) => (
          <li key={note.id}>
            {note.content}
            <form action={deleteNote.bind(null, note.id)}>
              <button type="submit">Delete</button>
            </form>
          </li>
        ))}
      </ul>
    </>
  );
}

export default function NotesPage() {
  return (
    <main>
      <h1>Your notes</h1>
      <form action={addNote}>
        <input name="content" maxLength={500} required />
        <button type="submit">Add note</button>
      </form>
      <Suspense fallback={<p>Loading notes...</p>}>
        <NotesList />
      </Suspense>
      <form action={logout}>
        <button type="submit">Log out</button>
      </form>
    </main>
  );
}

Some notes on this page:

  • Reading cookies makes NotesList render per request, and the Suspense boundary lets the page shell (heading and form) appear immediately while the query runs. It's also the structure Next.js expects if you enable Cache Components.
  • getUser() sends a request to Supabase Auth to validate the token. For server-side authorization, prefer it (or getClaims(), which verifies the JWT signature) over getSession(), which trusts whatever is in the cookie.
  • deleteNote.bind(null, note.id) creates a Server Action with the ID pre-filled. Like any argument to a Server Action, the server receives it from the client, so treat it as user input.

Mutating Data with Server Actions

// src/app/notes/actions.ts
"use server";

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

export async function addNote(formData: FormData) {
  const content = String(formData.get("content") ?? "").trim();
  if (!content || content.length > 500) return;

  const supabase = await createClient();
  const { error } = await supabase.from("notes").insert({ content });

  if (error) throw new Error(error.message);
  revalidatePath("/notes");
}

export async function deleteNote(id: number) {
  const supabase = await createClient();
  const { error } = await supabase.from("notes").delete().eq("id", id);

  if (error) throw new Error(error.message);
  revalidatePath("/notes");
}

There's no ownership check in this code, and there doesn't need to be one. If a user tampers with the ID and tries to delete someone else's note, the delete policy filters that row out and nothing happens. The insert doesn't send user_id; the column default fills it from auth.uid(), and the insert policy confirms it matches.

revalidatePath("/notes") tells Next.js to re-render the page, so the list updates in the same round trip as the action. For forms that need inline errors and pending states, use useActionState, as covered in Server Actions in Next.js.

Generating TypeScript Types

The Database generic in the client factories comes from your actual schema. Generate it with the Supabase CLI:

npx supabase login
npx supabase gen types typescript --project-id your-project-ref > src/types/database.types.ts

Now supabase.from("notes") autocompletes table names, select("id, content, created_at") returns a typed object with exactly those fields, and insert rejects unknown columns. Regenerate the file whenever you change the schema, ideally as part of your migration workflow.

Adding Realtime Updates

Supabase can stream database changes over WebSockets. First, enable it for the table:

alter publication supabase_realtime add table public.notes;

Realtime respects RLS for postgres_changes, so each user only receives events for rows they're allowed to select. A Client Component can listen and refresh the server-rendered list when something changes:

// src/app/notes/live-notes.tsx
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/client";

export function LiveNotes({ userId }: { userId: string }) {
  const router = useRouter();

  useEffect(() => {
    const supabase = createClient();

    const channel = supabase
      .channel(`notes:${userId}`)
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: "notes",
          filter: `user_id=eq.${userId}`,
        },
        () => router.refresh(),
      )
      .subscribe();

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

  return null;
}

router.refresh() re-runs the Server Components for the current route and merges the result without losing client state. That keeps the server as the single source of truth: the realtime event is just a signal to re-fetch. Open the app in two tabs, add a note in one, and it appears in the other.

For high-frequency updates you'd apply the payload directly to client state instead of refreshing, but for a notes list this approach is simpler and avoids duplicating rendering logic.

Deploying

Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY in your hosting provider's environment settings. Then, in the Supabase dashboard under Authentication URL configuration, set the Site URL to your production domain and add any preview domains to the redirect allowlist. If confirmation emails point at localhost after deploying, that setting is the reason.

Before going live, it's worth checking:

  • Every table in the public schema has RLS enabled. The dashboard's security advisor flags tables that don't.
  • No secret or service_role key is used in code that can reach the browser.
  • The email provider is configured for production volume; the built-in one is rate-limited and meant for development.

Conclusion

Supabase and Next.js fit together cleanly once the responsibilities are clear. Postgres row level security decides who can see and change which rows. @supabase/ssr keeps the session in cookies so both server and browser know the user. Proxy refreshes tokens before rendering, Server Components query directly, and Server Actions handle writes with revalidatePath keeping the UI in sync.

From here you can add Supabase Storage for file uploads, OAuth providers for social sign-in, or database functions for logic that belongs next to the data. The pattern stays the same: a per-request server client, RLS on every table, and Next.js handling the rendering.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading