Magic Link Auth in Next.js with Supabase: The Complete Setup

10 min readsupabase, nextjs, auth

Magic link auth is the lowest-friction sign-in you can ship: the user types an email address, clicks the link that arrives, and they are in. No passwords to hash, no reset flow to build, no credential-stuffing surface. Supabase handles the token generation and email delivery, but wiring it into the Next.js App Router correctly still has real moving parts — the session has to live somewhere server components can read it, the emailed link has to be exchanged for a session on your server, and something has to keep refreshing tokens so users are not silently logged out an hour after they sign in.

By the end of this post you will have the complete setup with @supabase/ssr: why cookie-based sessions beat localStorage in the App Router, the three Supabase clients and when each one runs, a login form calling signInWithOtp, the /auth/callback route that exchanges the code for a session, session refresh and route protection in middleware, and the gotchas that eat an afternoon the first time — the redirect URL allowlist, why links sometimes fail on a different device, and email rate limits in development.

Every snippet is lifted from the code running this site. The dashboard for the link shortener that powers this site sits behind exactly this flow, so this is the version that survived contact with production, not a minimal demo.

Why cookies instead of localStorage

The plain @supabase/supabase-js client stores the session in localStorage by default. That is fine for a pure single-page app, but the App Router splits rendering between server and client, and the server cannot read localStorage. If the session only exists in the browser, every server component renders as an anonymous user, and you end up fetching all user data client-side — throwing away most of the point of server components.

@supabase/ssr solves this by persisting the session in cookies instead. Cookies travel with every request, so server components, route handlers, and middleware can all see who is signed in. The library handles the fiddly parts — serializing the session, splitting it across multiple cookies when it exceeds size limits, and reading it back on both sides. The official Supabase server-side auth guide for Next.js is built around the same approach; what follows is that pattern with the sharp edges annotated.

The three Supabase clients and when each runs

A working setup needs three distinct clients, and mixing them up is the most common source of confusing bugs. Here is the map:

ClientCreated withRuns inKey
BrowsercreateBrowserClientClient componentsanon
ServercreateServerClientServer components, route handlers, middlewareanon
AdmincreateClient from supabase-jsTrusted server code onlyservice role

The browser client is a one-liner. It manages cookies itself via document.cookie, so no configuration is needed:

// src/lib/supabase/client.ts
"use client";

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

export function createSupabaseBrowserClient() {
  return createBrowserClient(
    env.SUPABASE_URL,
    env.SUPABASE_ANON_KEY,
  );
}

The server client needs to be told how to read and write cookies, because that differs by context in Next.js. It reads from the request via cookies() and writes back when it can:

// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { env } from "@/lib/env";

export async function createSupabaseServerClient() {
  const cookieStore = await cookies();
  return createServerClient(
    env.SUPABASE_URL,
    env.SUPABASE_ANON_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 — refresh happens
            // in middleware instead.
          }
        },
      },
    },
  );
}

That try/catch is not sloppiness. Next.js forbids setting cookies during server component rendering — only route handlers, server actions, and middleware can write them. When the Supabase client tries to persist a refreshed token from inside a server component, the write throws, and swallowing it is safe precisely because middleware (covered below) refreshes the session on every matched request anyway.

The admin client uses the service role key, which bypasses Row Level Security entirely. It belongs in server code that must act across users — background jobs, webhooks, aggregation queries — and nowhere near anything a browser can reach. It also disables session persistence, since it has no user session to persist. If you rely on it a lot, that is usually a sign your RLS policies need work rather than a reason to route more code through it.

Sending the magic link with signInWithOtp

The login form is a client component, so it uses the browser client. The interesting part is emailRedirectTo — the URL Supabase sends the user back to after verifying the emailed link:

// src/app/login/login-form.tsx (essentials)
const supabase = createSupabaseBrowserClient();
const redirectTo =
  window.location.origin +
  "/auth/callback" +
  (next ? "?next=" + encodeURIComponent(next) : "");

const { error } = await supabase.auth.signInWithOtp({
  email,
  options: { emailRedirectTo: redirectTo },
});

Three things worth noting. First, signInWithOtp handles both sign-up and sign-in — if the email has never been seen before, a user is created by default, which is exactly what you want for a passwordless flow. Second, the next parameter (where the user was originally headed) is folded into the redirect URL so it survives the round trip through the email. Third, the same redirectTo works for OAuth: the login form here also offers signInWithOAuth with Google, and both flows land on the same callback route, so you build the exchange logic once.

The form itself just tracks a small state machine — idle, sending, sent, error — and tells the user to check their inbox. There is nothing else to build on the client. No token handling, no session juggling; the browser client picks the session up from cookies after the callback completes.

The /auth/callback route: exchanging the code for a session

When the user clicks the emailed link, they first hit the Supabase auth server, which verifies the token and redirects to your emailRedirectTo URL with a code query parameter. Your job is to exchange that code for a session in a route handler — a context where cookie writes are allowed:

// src/app/auth/callback/route.ts
import { NextResponse } from "next/server";
import { createSupabaseServerClient } from "@/lib/supabase/server";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const next = url.searchParams.get("next") || "/dashboard";

  if (code) {
    const supabase = await createSupabaseServerClient();
    const { error } =
      await supabase.auth.exchangeCodeForSession(code);
    if (error) {
      const err = encodeURIComponent(error.message);
      return NextResponse.redirect(
        new URL("/login?error=" + err, url.origin),
      );
    }
  }

  return NextResponse.redirect(new URL(next, url.origin));
}

exchangeCodeForSession does the heavy lifting: it sends the code (plus a stored verifier — more on that under gotchas) to Supabase, gets back access and refresh tokens, and writes them into cookies through the adapter you configured. On failure the user goes back to /login with a readable error; on success they continue to wherever next points.

One hardening step worth adding before you ship: validate that next is a same-site path — it should start with a single / and not // — otherwise a crafted link can turn your callback into an open redirect to an arbitrary domain.

Session refresh and protected routes in middleware

Supabase access tokens are short-lived JWTs. Something on the server has to use the refresh token to mint new ones, and since server components cannot write cookies, that job lands in middleware. In Next.js 16 that file is proxy.ts rather than middleware.ts — same concept, new name and a proxy export, which I covered in the middleware-to-proxy migration post. The refresh helper builds a client wired to both the incoming request and the outgoing response:

// src/lib/supabase/session.ts
export async function updateSupabaseSession(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    env.SUPABASE_URL,
    env.SUPABASE_ANON_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),
          );
        },
      },
    },
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  return { response, user };
}

The call to getUser() matters. It contacts the Supabase auth server to validate the token — refreshing it if it has expired — whereas getSession() just decodes whatever is in the cookie without verifying it. For anything that gates access, use getUser(). The double cookie write (request and response) looks odd but is deliberate: it keeps the refreshed token visible to server components rendering during this same request, while also sending the new cookie back to the browser.

The proxy then scopes all of this to the routes that need it and enforces access:

// src/proxy.ts
export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (
    !pathname.startsWith("/dashboard") &&
    !pathname.startsWith("/api/links")
  ) {
    return NextResponse.next();
  }

  const { response, user } = await updateSupabaseSession(request);

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

  if (!user && pathname.startsWith("/api/links")) {
    return NextResponse.json(
      { error: "unauthorized" },
      { status: 401 },
    );
  }

  return response;
}

export const config = {
  matcher: ["/dashboard/:path*", "/api/links/:path*"],
};

Two details here pay for themselves. Scoping via the matcher (and the early return) means public pages — including every shortlink redirect on this site — never pay the latency of an auth-server round trip. And the two unauthorized cases get different treatment: humans hitting a page get redirected to login, while API calls get a clean 401 JSON body instead of an HTML login page their client cannot parse.

The next= redirect pattern, end to end

The next parameter is what makes the flow feel polished: a signed-out user who bookmarks a deep dashboard link ends up exactly there after signing in, not dumped on a generic landing page. The loop has five hops:

  1. User requests /dashboard/links; the proxy finds no user and redirects to /login?next=/dashboard/links.
  2. The login page passes next into the form, which folds it into emailRedirectTo.
  3. The emailed link verifies at Supabase and redirects to /auth/callback?next=/dashboard/links&code=....
  4. The callback exchanges the code, then redirects to next.
  5. The proxy runs again, finds a valid session this time, and lets the request through.

The login page also closes the loop for users who are already signed in: it calls getUser() server-side and immediately redirects to next (or the dashboard) so nobody sees a login form they do not need.

The gotchas that actually bite

The redirect URL allowlist

Supabase will not redirect to arbitrary URLs. In the dashboard, under Authentication and then URL Configuration, you set a Site URL and a list of additional allowed redirect URLs. If your emailRedirectTo does not match an entry, Supabase does not error — it silently falls back to the Site URL. The classic symptom: you test locally, click the emailed link, and land on your production homepage with no session and no error message. Add your local callback URL (for example http://localhost:3000/auth/callback) to the allowlist, and if you use preview deployments, the allowlist supports wildcard patterns so you can cover them in one entry.

Links failing on another device: PKCE realities

A widely repeated claim says magic links only work in the browser that requested them. Calling it a Supabase bug is a misconception, but the underlying behavior is real, and it comes from PKCE, the flow @supabase/ssr uses by default. When the form calls signInWithOtp, the library generates a secret code verifier and stores it in that browser. The emailed code is only half of the credential; exchangeCodeForSession must present the verifier too. Open the link on the laptop that requested it and everything works. Open it on your phone, and the verifier is missing, so the exchange fails and the user bounces back to login with an opaque error.

That is a security property, not an accident — a code intercepted in transit is useless without the verifier. A related trap: corporate email scanners that prefetch links can consume a one-time link before the user ever clicks it. If cross-device links genuinely matter for your product, Supabase documents an alternative in its auth guides: customize the email template to carry the OTP token hash and verify it server-side with verifyOtp, which does not depend on state stored in the requesting browser. For a dashboard like mine, where people sign in on the machine they are working on, the default PKCE flow is the right trade.

Email rate limits in development

Supabase projects ship with a built-in email service intended for development only, and it has a very low hourly cap on auth emails. If you are iterating on this flow — and you will send yourself a lot of links while testing — you will hit it fast and start seeing rate limit errors from signInWithOtp. Two fixes: configure a custom SMTP provider in the dashboard, which lifts you to your provider’s limits and makes the rate limits configurable, or run the local Supabase stack via the CLI, which captures outgoing auth emails in a local inbox so no real email is sent at all. The second is the better development loop anyway.

Bottom line

The whole setup is five small files: a browser client, a server client with a cookie adapter, a login form calling signInWithOtp with emailRedirectTo, a callback route calling exchangeCodeForSession, and a proxy that refreshes sessions with getUser() and gates protected paths. Get the redirect allowlist right before you test, expect same-browser behavior from PKCE links, and move email off the built-in service before it throttles you. Once the session lives in cookies, everything downstream gets simpler: server components know who the user is, API routes return honest 401s, and your RLS policies see auth.uid() on every query without any extra plumbing.