# 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.

Source: https://shipanysaas-demo.vercel.app/blog/nextjs-supabase-magic-link
Published: 2026-10-18

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.

## How a magic login link works in Supabase

`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](https://supabase.com/docs/guides/auth/auth-email-passwordless) 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](https://supabase.com/docs/guides/auth/redirect-urls) suggests `http://localhost:3000/**`, and `https://*-<team-or-account-slug>.vercel.app/**` for Vercel previews. In production, list exact paths instead.

## Step 2: send the link from your sign-in form

The form is a small Client Component. It needs the browser client from Supabase's [Next.js server-side auth guide](https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=nextjs), the one built with `createBrowserClient`:

```tsx
'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:

```html
<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:

```typescript
// 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](https://supabase.com/docs/guides/auth/auth-email-templates) 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:

```typescript
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](https://supabase.com/docs/guides/auth/auth-smtp) 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](https://shipanysaas-demo.vercel.app/blog/nextjs-supabase-resend-email) walks through one provider end to end, with the SMTP settings for Supabase.

## Magic link vs email OTP

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](https://shipanysaas-demo.vercel.app/auth/sign-in) 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](https://shipanysaas-demo.vercel.app/docs/authentication/email-password), and the plans are on the [pricing](https://shipanysaas-demo.vercel.app/pricing) page.

## Questions people ask

### How long does a Supabase magic link stay valid?

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.

### Why does my magic link say it expired when I clicked it right away?

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.
