Skip to content
shipanysaas

How to add magic link login to a Next.js + Supabase app

Magic link login for Next.js with Supabase Auth: signInWithOtp, a token-hash confirm route, email codes as a backup and custom SMTP for production.

A magic login link signs a user in with one click from their inbox: no password to create, forget or reset. In a Next.js App Router app on Supabase Auth, one call sends it, signInWithOtp(), and one route handler turns the click into a session.

How do you add it? Allow your app's URLs in Supabase, send the link from a Client Component, edit the magic link email so it points at your own /auth/confirm route with a token hash, and verify that hash with verifyOtp(). Put a six-digit code in the same email for when the link fails, and connect your own SMTP provider before real users arrive. Here is each step.

signInWithOtp() sends a magic link by default. The same call can send a one-time code instead, because the only difference is what the email template contains. Supabase's passwordless guide lists the defaults: a user can request one link every 60 seconds, each link works once, and it expires after an hour.

The call also signs people up. If no account exists for the address, Supabase creates one, unless you pass shouldCreateUser: false. Use false on the sign-in page and true on the sign-up page, so a typo on sign-in doesn't create a stray account. One catch: a new address doesn't get the magic link email. When email confirmation is on, which is the default for hosted projects, Supabase sends the Confirm sign up email instead, so that template needs the same change as the magic link one.

With @supabase/ssr, auth runs on the PKCE flow, and that decides how the link finishes. Supabase's default email template links to Supabase, which verifies the token and redirects to your app with a code. Your callback swaps that code for a session with exchangeCodeForSession(), but only in the browser that asked for the link, because the exchange needs a verifier stored in that browser's cookies. Request a link on a laptop and open it on a phone, and the exchange fails.

The fix, and what Supabase recommends for PKCE, is a template that sends a token hash to your own route. verifyOtp() checks the hash on the server, so the link works in whichever browser opens it.

Step 1: allow your redirect URLs

Supabase only sends users to URLs you allow. On the URL Configuration page, set the Site URL to your production domain and add the other URLs your app redirects to. The Site URL also fills {{ .SiteURL }} in email templates, so a leftover http://localhost:3000 sends production users to localhost.

For local development and preview deployments, wildcards help. Supabase's redirect URL guide suggests http://localhost:3000/**, and https://*-<team-or-account-slug>.vercel.app/** for Vercel previews. In production, list exact paths instead.

The form is a small Client Component. It needs the browser client from Supabase's Next.js server-side auth guide, the one built with createBrowserClient:

'use client';

import { useState } from 'react';

import { createClient } from '@/lib/supabase/client';

export function MagicLinkForm() {
  const [status, setStatus] = useState<'idle' | 'sent' | 'error'>('idle');

  async function sendLink(formData: FormData) {
    const supabase = createClient();

    const { error } = await supabase.auth.signInWithOtp({
      email: String(formData.get('email')),
      options: { shouldCreateUser: false },
    });

    // an unknown address returns otp_disabled: answer the same way, so
    // the form doesn't reveal who has an account
    setStatus(error && error.code !== 'otp_disabled' ? 'error' : 'sent');
  }

  if (status === 'sent') {
    return <p>If that address has an account, a sign-in link is on its way.</p>;
  }

  return (
    <form action={sendLink}>
      <input name="email" type="email" autoComplete="email" required />
      <button type="submit">Email me a link</button>
      {status === 'error' && <p>We couldn't send the link. Try again.</p>}
    </form>
  );
}

On success, data.user and data.session are both null. Nothing is signed in yet, so tell the user to check their email. With shouldCreateUser: false, an address with no account gets an otp_disabled error instead. Showing a different message for it would let anyone test which emails have accounts, so the form answers both cases with the same message.

Step 3: point the email at your confirm route

Edit the Magic link template in the dashboard's email templates. This version links to your app with a token hash and adds the code as a backup:

<h2>Sign in</h2>
<p>
  <a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
    Sign in
  </a>
</p>
<p>Or enter this code: {{ .Token }}</p>

Give the Confirm sign up template the same link and code, because that is the email a new address receives. A hosted project doesn't read your local supabase/config.toml, so set both templates in the dashboard. Locally, the CLI serves a template file when config.toml points to it.

Step 4: verify the token in a route handler

The link opens /auth/confirm. A route handler verifies the hash with the server client, which writes the session cookies, then redirects:

// app/auth/confirm/route.ts
import type { EmailOtpType } from '@supabase/supabase-js';
import { type NextRequest, NextResponse } from 'next/server';

import { createClient } from '@/lib/supabase/server';

export async function GET(request: NextRequest) {
  const { searchParams, origin } = 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) {
      return NextResponse.redirect(new URL('/dashboard', origin));
    }
  }

  return NextResponse.redirect(new URL('/auth/error', origin));
}

/auth/error is a page of your own that explains what went wrong and offers a new link. If you add a next parameter so users land back where they started, treat it as untrusted input. A check for a leading / alone lets //evil.example through and turns your route into an open redirect. Resolve the value against your origin, inside a try, and follow it only when the origin still matches.

From here, the session is a normal one. proxy.ts refreshes it on each request, and pages check the user with getClaims(), as Supabase's Next.js guide describes.

Step 5: let users type the code instead

Some links die before the user clicks them. Supabase's email template docs warn that security scanners, such as Safe Links in Microsoft Defender for Office 365, open links in incoming mail. That uses up the one-time link, and the user sees "Token has expired or is invalid". Other users read mail on one device and sign in on another.

The code from Step 3 covers both cases. Add a field for it, and verify it with the email address:

const { error } = await supabase.auth.verifyOtp({
  email,
  token: code,
  type: 'email',
});

That runs in the browser, and the session cookies land in the same browser the user is typing in.

Before launch: send through your own SMTP

Supabase's built-in email sender is for trying things out. Its custom SMTP guide says it only delivers to members of your project's team, and that the limit is currently 2 messages an hour. Every other address fails with "Email address not authorized".

Set up custom SMTP under the project's Auth settings before launch. Afterwards Supabase starts you at 30 messages an hour, which you can raise on the rate limits page. The Resend email guide walks through one provider end to end, with the SMTP settings for Supabase.

They are one feature and one email template, so you don't have to choose:

  • A magic link is one click on the device that reads the email. It can be eaten by link scanners, and it is awkward when the inbox is on another device.
  • An email OTP is a six-digit code typed into the open tab. It survives scanners and works across devices, at the cost of a little typing.

Sending both in one email gives users whichever suits them.

How the kit does it

shipanysaas has magic links and email codes built in, each behind an environment variable: NEXT_PUBLIC_AUTH_MAGIC_LINK and NEXT_PUBLIC_AUTH_OTP. Both are off by default, while password sign-in is on. magic-link-auth-container.tsx in packages/features/auth sends the link, with shouldCreateUser set to false on the sign-in page and true on sign-up.

The magic link template, apps/web/supabase/templates/magic-link.html, carries both the link and the code, and confirm-email.html uses the same token-hash link, so new addresses can sign up the same way. The link goes to /auth/confirm, and the route checks it with verifyOtp(). Before it redirects, isSafeRedirectPath rejects anything that could leave the site: a leading double slash, a protocol, backslashes, control characters, and any path that resolves to another origin. When magic links or codes are on, people invited to a team can join with their email alone. Locally, every auth email lands in Mailpit at http://localhost:54324, so you can click links without a real inbox.

The live demo has emails turned off, so it signs in with a password only, but you can see the rest of the app. The setup is documented in Email sign-in, and the plans are on the pricing page.

Questions people ask

One hour by default, and each link works once. The Email OTP Expiration setting controls it, and that setting also applies to confirmation, password reset, email change and invitation links.

Usually a scanner opened it first, or the user clicked an older email after requesting a newer one. Sending a code alongside the link gives them a way in either way.

Why do I get "Email rate limit exceeded"?

You are probably on the built-in email sender, which allows very few messages an hour. Set up custom SMTP, then adjust the limits on the rate limits page.

Can the same flow sign up new users?

Yes. signInWithOtp() creates the account when the email is new, unless you pass shouldCreateUser: false. With email confirmation on, the first email a new address gets is the Confirm sign up email, so give that template the same token-hash link.