How to send sign-up emails from Next.js

Set up Supabase sign-up confirmation, test it locally, and understand when your app should send a separate welcome email.

By Antoine RossPublished Facts verified

For an app using Supabase Auth, let Supabase send the sign-up confirmation email and verify the link. Your Next.js app starts sign-up and exchanges the returned code for a session. A separate welcome email is an optional application message; it must not replace confirmation or grant access.

This walkthrough uses a local Supabase project and its captured mail, so you can verify the flow without delivering a message to a real inbox. Supabase documents password sign-up and local email testing.

Before you start

Complete how to add authentication to Next.js, including its cookie-backed lib/supabase/server.ts and lib/supabase/client.ts, session-refresh boundary and protected /dashboard. Install @supabase/supabase-js and @supabase/ssr as described there. You need the Supabase CLI, a supported container runtime, and a local Supabase project connected to the app through NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.

This example assumes Next.js App Router, the @/ alias for your source root, and a local site origin of http://localhost:3000. In the existing sections of , set these values (merge them into the sections if they already exist):

supabase/config.toml
[auth]
site_url = "http://localhost:3000"
additional_redirect_urls = ["http://localhost:3000/auth/callback"]

[auth.email]
enable_confirmations = true

From the initialized Supabase project, run supabase start, or restart the local stack if it was already running when you changed the configuration. Run supabase status to find its API URL, publishable key and Mailpit URL. Configure your app with the local URL and publishable key, then restart the app. Status output can contain other local credentials, so do not share it or copy it wholesale into a log. Supabase documents this setup and the different local and hosted defaults in its local Auth testing guide.

1. Start sign-up on the server

Add a Server Action that calls Supabase Auth. Set SITE_URL in your local server environment to http://localhost:3000; use your trusted public origin in the deployed environment. Never derive the destination from a form field.

app/signup/actions.ts
'use server';

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

export async function signUp(formData: FormData) {
  const email = String(formData.get('email') ?? '').trim();
  const password = String(formData.get('password') ?? '');
  const siteUrl = process.env.SITE_URL;
  if (!siteUrl || !email || !password) redirect('/signup?result=error');

  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: { emailRedirectTo: new URL('/auth/callback', siteUrl).toString() },
  });
  if (error) redirect('/signup?result=error');
  redirect('/signup?result=check-email');
}

The cookie-aware server client stores the PKCE verifier for the callback. Confirm-email must remain enabled for this example; a successful sign-up response is not confirmation that the person owns the address. Add your own password policy, abuse prevention and accessible error handling before release. Keep responses generic so the form does not disclose whether an address already has an account.

2. Connect the form

app/signup/page.tsx
import { signUp } from './actions';

export default async function SignupPage({
  searchParams,
}: {
  searchParams: Promise<{ result?: string }>;
}) {
  const { result } = await searchParams;
  return (
    <main>
      <h1>Create an account</h1>
      {result === 'check-email' && <p>Check your email for the confirmation link.</p>}
      {result === 'error' && <p role="alert">Sign-up could not be completed. Please try again.</p>}
      <form action={signUp}>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" autoComplete="email" required />
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" autoComplete="new-password" required />
        <button type="submit">Create account</button>
      </form>
    </main>
  );
}

This page uses the asynchronous searchParams convention in current App Router releases; adapt the prop to your installed Next.js version. If the app already has a sign-up page, add this action to its existing form rather than replacing other flows.

3. Exchange the confirmation code

If you already have a Supabase PKCE callback, extend that owner instead of creating a conflicting route. Otherwise add:

app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';

export async function GET(request: Request) {
  const siteUrl = process.env.SITE_URL;
  if (!siteUrl) return new Response('Site URL is not configured', { status: 500 });
  const code = new URL(request.url).searchParams.get('code');
  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) return NextResponse.redirect(new URL('/dashboard', siteUrl));
  }
  return NextResponse.redirect(new URL('/signup?result=error', siteUrl));
}

The destination is a fixed path on the configured origin, not an untrusted next URL. The code is single-use; the exchange requires the verifier stored in the browser that started sign-up. Supabase explains the PKCE flow and same-browser requirement. Do not log codes or confirmation URLs.

4. Set the confirmation email and test locally

Use Supabase Auth's Confirm signup email template, retaining its provider-generated confirmation link. For example, the template can contain a short explanation and a link whose href is the documented {{ .ConfirmationURL }} variable. The template reference documents the exact variables and warns that link rewriting or mail security prefetching can affect single-use links. Avoid click tracking on authentication links.

Start your local Supabase services and Next.js app through their normal commands. Open /signup, submit a new disposable address and password, then open the local Mailpit inbox shown by your Supabase setup. Open the captured confirmation link in the same browser that submitted the form. Expected result: the callback exchanges the code, /dashboard loads as the new user, and a reload keeps the session. Before confirmation, the protected page must reject the user. Sign out and confirm it rejects a fresh request; a missing, expired or already-used code must not establish a new session.

Local Mailpit capture is not delivery proof. If no message appears, check that the app points at the local Supabase project, confirmation is enabled, and the local email rate limit was not reached. If exchange fails, check the exact redirect allow list, origin, same-browser verifier and whether the code was already consumed.

5. Prepare delivery for real users

Supabase's default hosted mail service is restricted and intended for exploration. For production confirmation, configure an SMTP provider in Supabase Auth using that provider's SMTP host, port and credentials. Follow Supabase's custom SMTP instructions. Your email provider tells you which DNS records to add; add and verify them with the domain's DNS owner, including the provider's SPF and DKIM instructions and an appropriate DMARC policy. Check provider verification and delivery logs, then perform an authorized test to an inbox you control. A local capture cannot establish DNS authentication or inbox delivery.

SMTP credentials belong in Supabase's server-side configuration. Any API key used for a separate app email belongs only on the server or in its secret manager, never in a NEXT_PUBLIC_ variable or browser bundle.

A welcome email is a separate job

A welcome message can explain the first product action after confirmation. If you add one, trigger it from a trusted, confirmed-user event or server-side workflow, persist its delivery state and handle retries without sending duplicates. Do not send it merely because the browser reached a success URL, and do not use it to verify an address. Marketing mail is a separate consent and preference decision.

CoreMVP's authentication Docs describe its Supabase confirmation and callback path, so the founder can start with the session transition already connected. The customer still configures delivery and their own sender domain.