DEV Community

Alec Winter
Alec Winter

Posted on

Building Magic Link Authentication with Next.js Server-Side Rendering and Supabase

Title image illustration

Building authentication is one of those things that looks easy at first — until it isn’t. You want something secure, user-friendly, and boring in production (boring is good). At the same time, users don’t want to deal with passwords, resets, or friction just to get started.

In this article, I’ll walk through how to build magic link authentication using Supabase and Next.js 16, leaning on server-side rendering where it actually makes sense. We’ll start with the problem, move through the ideas behind the setup, and only then get into the implementation details.

No buzzwords up front. Just a practical solution.

Why Magic Links?

Password-based authentication has a long list of problems. Users reuse passwords, forget them, or pick ones that are way too weak. From a product perspective, every password field is another chance for users to drop off.

Magic links flip this around. Instead of asking users to remember yet another secret, you send them a one-time login link via email. They click it, they’re in. No password resets, no credential stuffing, no extra cognitive load.

It’s not a silver bullet, but for many products it’s a very solid default.

Where Server-Side Rendering Fits In

Using SSR doesn’t magically make authentication more secure — your API still needs to enforce access rules properly. What SSR does give you is control over when and how authentication checks happen.

With the Next.js App Router and React Server Components, you can:

  • Resolve authentication before rendering a page
  • Avoid client-side loading flicker for auth checks
  • Keep session handling on the server

This leads to simpler page logic and a more predictable user experience.

The Core Idea

The setup is based on a few simple concepts:

Authentication state lives in HTTP-only cookies managed by Supabase. Server Components read that state directly. Middleware acts as a gatekeeper for protected routes, so unauthenticated users never reach pages they shouldn’t see.

Nothing fancy — just a clear separation of concerns.

Architecture Overview

Setting Up Supabase for SSR

The first step is creating a Supabase client that works correctly in a server-rendered environment. The key detail here is cookie handling. Next.js exposes cookies through its own API, and Supabase needs access to them to manage sessions.

// lib/supabase/client.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { cache } from "react";

export const getSupabaseClient = cache(async () => {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            cookieStore.set(name, value, options);
          });
        },
      },
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

The client is cached per request to avoid unnecessary re-creation, and cookie writes are handled in a way that works with Server Components.

Sending the Magic Link

To start the sign-in flow, you only need a simple server action. Supabase handles generating and emailing the link.

// lib/actions/auth.ts
"use server";

import { getSupabaseClient } from "../supabase/client";

export async function signInWithMagicLink(email: string) {
  const supabase = await getSupabaseClient();

  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_URL}/auth/callback`,
    },
  });

  if (error) {
    return { success: false };
  }

  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

At this point, the user just sees a “check your email” message. The interesting part happens when they come back.

Exchanging the Code for a Session

When the user clicks the magic link, Supabase redirects them back with a short-lived code. That code needs to be exchanged for a session — again, on the server.

export async function exchangeCodeForSession(code: string) {
  const supabase = await getSupabaseClient();
  const { error } = await supabase.auth.exchangeCodeForSession(code);

  if (error) {
    return;
  }

  revalidatePath("/", "layout");
  redirect("/dashboard");
}
Enter fullscreen mode Exit fullscreen mode

Using a server action here keeps everything nicely integrated with Next.js: redirects are type-safe, cache invalidation is explicit, and there’s no extra API route to maintain.

On the client, a small component reads the URL parameter and triggers the action once.

"use client";

import { useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { exchangeCodeForSession } from "@/lib/actions/auth";

export function SignInValidation() {
  const searchParams = useSearchParams();
  const code = searchParams.get("code");

  useEffect(() => {
    if (code) {
      exchangeCodeForSession(code);
    }
  }, [code]);

  return code ? <div>Signing you in</div> : null;
}
Enter fullscreen mode Exit fullscreen mode

Protecting Routes Early

Middleware is used as an early guard for protected routes. The goal here isn’t extra security — the backend still enforces access — but efficiency and clarity.

Unauthenticated requests get redirected before any page logic runs.

In Next.js 16, this file is often named proxy.ts instead of the traditional middleware.ts. The reason is mostly conceptual: at this point in the request lifecycle, the file acts less like classic middleware and more like a lightweight proxy. It sits in front of your application, inspects the incoming request, and decides whether it should continue, be redirected, or be blocked — without being part of your app’s actual routing or rendering logic.

// proxy.ts
import { type NextRequest, NextResponse } from "next/server";
import { getSupabaseClient } from "@/lib/supabase/client";

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

  if (pathname.startsWith("/dashboard")) {
    const supabase = await getSupabaseClient();
    const { data: { user } } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.redirect(new URL("/signin", request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
Enter fullscreen mode Exit fullscreen mode

This keeps protected pages clean and avoids repeating auth checks everywhere.

A Minimal Sign-In Form

The UI can stay very simple. A single input, one button, done.

"use client";

import { useState } from "react";
import { signInWithMagicLink } from "@/lib/actions/auth";

export function SignInForm() {
  const [email, setEmail] = useState("");
  const [sent, setSent] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const result = await signInWithMagicLink(email);

    if (result.success) {
      setSent(true);
    }
  }

  if (sent) {
    return (
      <div>
        <h2>Check your email</h2>
        <p>We sent a magic link to {email}</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="you@example.com"
        required
      />
      <button type="submit">Send Magic Link</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

Magic link authentication combined with Supabase and Next.js SSR is a pragmatic setup. It reduces friction for users, keeps authentication logic close to the server, and avoids unnecessary complexity.

This approach will be used for helllo.me, a project I’m currently working on. It’s a digital contact card platform where fast onboarding and low friction matter more than fancy login screens.

If you’re building a modern web app and want authentication to be the least exciting part of your codebase, this setup gets you pretty close.

Top comments (0)