APIS

Magic Link Authentication in Next.js

How to build passwordless sign-in with email magic links using Supabase Auth, a React Email template, and a clean service wrapper around the OTP flow.

Passwords are a liability. Users forget them, reuse them, and get phished through them. Magic link authentication removes the password entirely: the user enters their email, you send them a sign-in link, they click it, and they're in. No credentials to remember, no credentials to steal.

I recently added magic link sign-in to a Next.js app that uses Supabase Auth. The implementation came down to three pieces: a service that wraps Supabase's OTP flow, a branded React Email template for the link, and a callback route that handles the token exchange.

The MagicLinkService

Supabase provides a signInWithOtp method out of the box, but I wanted a clean boundary between the auth layer and the rest of the app. A service class keeps the OTP details contained and makes the flow easy to test.

import { createClient } from '@/lib/supabase/server'
import { Mailer } from '@/lib/mailer'
import { MagicLinkEmail } from '@/lib/mailer/templates/magic-link'

export class MagicLinkService {
  async sendMagicLink(email: string) {
    const supabase = await createClient()

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

    if (error) throw error

    await Mailer.send(
      new MagicLinkEmail({ email, magicLinkUrl: '...' }),
      email,
    )
  }
}

The emailRedirectTo option tells Supabase where to send the user after they click the link. The URL must match a domain you've configured in your Supabase project settings. If it doesn't, the token exchange will fail silently.

One thing that caught me: Supabase sends its own email by default. If you want a custom template, you need to disable Supabase's built-in email delivery and handle it yourself. In the Supabase dashboard, go to Authentication > Email Templates and turn off the default templates, then handle sending in your service.

The React Email Template

React Email makes it straightforward to build transactional templates that look consistent with your brand. The magic link template needs three things: a clear call-to-action button, a fallback plain-text link, and a notice that the link expires.

import {
  Body,
  Button,
  Container,
  Head,
  Html,
  Preview,
  Text,
} from '@react-email/components'

interface MagicLinkEmailProps {
  email: string
  magicLinkUrl: string
}

export function MagicLinkEmail({ email, magicLinkUrl }: MagicLinkEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Sign in to your account</Preview>
      <Body style={main}>
        <Container style={container}>
          <Text style={heading}>Sign in to your account</Text>
          <Text style={text}>
            Click the button below to sign in as {email}. This link expires in
            24 hours.
          </Text>
          <Button style={button} href={magicLinkUrl}>
            Sign in
          </Button>
          <Text style={fallback}>
            If the button doesn't work, copy and paste this URL into your
            browser:
            <br />
            {magicLinkUrl}
          </Text>
        </Container>
      </Body>
    </Html>
  )
}

The fallback text is important. Email clients strip styles, block buttons, and generally make HTML emails unreliable. Including the raw URL as text gives the user a guaranteed way in.

For compliance, include an unsubscribe link and a postal address at the bottom of every transactional email. PECR (Privacy and Electronic Communications Regulations) in the UK and CAN-SPAM in the US both require it, even for transactional messages.

The Callback Route

After the user clicks the magic link, Supabase redirects them to your callback URL with the auth token in the query string. The callback route exchanges that token for a session.

import { createClient } from '@/lib/supabase/server'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url)

  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/dashboard'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }

  return NextResponse.redirect(`${origin}/login?error=auth`)
}

The key call is exchangeCodeForSession. This takes the one-time code from the URL and establishes a session. After that, you redirect based on whether the user is new or returning.

I redirect first-time users to an onboarding flow and returning users straight to the dashboard. You can detect a new user by checking the identities array on the user object. If it's empty or contains only the email identity with a recent created_at, the user signed up for the first time.

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

const isNewUser = user?.identities?.length === 1
  && new Date(user.identities[0].created_at) > new Date(Date.now() - 60000)

const redirectPath = isNewUser ? '/onboarding' : '/dashboard'

The Login Form

The login form needs a toggle between password and magic link. Most apps keep the password option as a fallback, so the UI lets the user choose their preferred method.

'use client'

import { useState } from 'react'
import { sendMagicLink } from '@/lib/actions/auth/magic-link'

export function LoginForm() {
  const [mode, setMode] = useState<'password' | 'magic-link'>('password')
  const [email, setEmail] = useState('')
  const [sent, setSent] = useState(false)

  async function handleMagicLink(formData: FormData) {
    await sendMagicLink(formData)
    setSent(true)
  }

  if (sent) {
    return (
      <div>
        <h2>Check your email</h2>
        <p>We sent a sign-in link to {email}. Click it to sign in.</p>
      </div>
    )
  }

  return (
    <form action={handleMagicLink}>
      <input
        type="email"
        name="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <button type="submit">Send magic link</button>
    </form>
  )
}

The server action sendMagicLink calls the MagicLinkService we defined earlier. It's a thin wrapper that maps errors to user-friendly messages.

Testing

Magic links are tricky to test end-to-end because the flow involves email delivery and token exchange. The approach I used was to capture outgoing emails in the test environment and extract the magic link URL from them.

import { MailCapture } from '@/lib/mailer/capture'

test('sends magic link email', async () => {
  await sendMagicLink({ email: '[email protected]' })

  const captured = MailCapture.last()
  expect(captured.subject).toContain('Sign in')
  expect(captured.to).toBe('[email protected]')

  const linkMatch = captured.html.match(/href="([^"]+)"/)
  expect(linkMatch).not.toBeNull()

  const magicLinkUrl = linkMatch![1]
  const response = await GET(new Request(magicLinkUrl))
  expect(response.status).toBe(307)
})

For integration tests, I mock the Supabase client at the module level. You don't want test runs sending actual OTP requests to Supabase, and you don't want to rely on email delivery timing in CI.

What to Watch For

Token expiry. Supabase defaults to a 24-hour expiry on magic link tokens. That's generous, but if your users are on mailing lists that delay delivery, they might hit the window. Adjust the expiry in your Supabase project settings if you see complaints.

Email deliverability. Magic links only work if the email arrives. Set up SPF, DKIM, and DMARC for your sending domain before going live. Without these, your transactional emails land in spam, and users think the feature is broken.

Rate limiting. Add a rate limit on the sendMagicLink endpoint. Without it, someone can spam the form and flood a victim's inbox, which is both an annoyance and a potential abuse vector. A simple approach: one magic link request per email per 60 seconds.

Conclusion

Magic link authentication removes passwords from the sign-in flow entirely. The implementation is three moving parts: a service that calls the OTP API, an email template with the link, and a callback route that exchanges the token for a session. The tricky parts are email deliverability (you need proper DNS records) and testing (you need to capture and inspect outgoing emails). Once those are in place, the feature is reliable and low-maintenance.

← Older
REST API Documentation
Newer →
Including Related Data in API Responses

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.