How do I integrate Resend with Next.js (App Router) for password reset and magic link emails?
Communications APIs (CPaaS)

How do I integrate Resend with Next.js (App Router) for password reset and magic link emails?

14 min read

Building password reset and magic link flows with Next.js (App Router) and Resend is a clean way to modernize your authentication UX without relying on bulky third-party auth UIs. With the right setup, you can send email links securely, handle token verification on the server, and keep your API routes organized inside the app directory.

This guide walks through a practical, end-to-end setup so you can integrate Resend with Next.js (App Router) for password reset and magic link emails in a production-ready way.


Overview of the architecture

To integrate Resend with Next.js (App Router) for password reset and magic link flows, you’ll typically need:

  • A Resend account and API key
  • A Next.js project using the App Router (app directory)
  • A database (e.g., PostgreSQL, MySQL, or Prisma ORM) to store users and tokens
  • API routes under app/api to:
    • Request a password reset or magic link
    • Send the email via Resend
    • Validate the token when the user clicks the email link
  • A secure token generation strategy (short-lived, one-time usage)

At a high level:

  1. User submits email address.
  2. Your API route generates a token, stores it in the database, and sends an email via Resend.
  3. User clicks the link from the email (with the token as a query parameter).
  4. A Next.js route validates the token and either:
    • Shows a password reset form, or
    • Automatically logs the user in (magic link) and sets a session/cookie.

Step 1: Install and configure Resend in Next.js

First, install the Resend SDK:

npm install resend
# or
yarn add resend
# or
pnpm add resend

Create an environment variable in your .env.local:

RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM="Your App <no-reply@yourdomain.com>"
APP_URL=http://localhost:3000

APP_URL is used to build password reset / magic link URLs. In production, set this to your real domain.

Now create a small Resend utility, e.g. lib/resend.ts:

// lib/resend.ts
import { Resend } from 'resend';

if (!process.env.RESEND_API_KEY) {
  throw new Error('RESEND_API_KEY is not set');
}

export const resend = new Resend(process.env.RESEND_API_KEY);
export const EMAIL_FROM = process.env.EMAIL_FROM ?? 'no-reply@example.com';
export const APP_URL = process.env.APP_URL ?? 'http://localhost:3000';

Step 2: Design the database schema for tokens

You need somewhere to store password reset and magic link tokens. This can be a dedicated table with:

  • id
  • email or userId
  • token (random string)
  • type ('PASSWORD_RESET' | 'MAGIC_LINK')
  • expiresAt
  • usedAt (nullable)
  • createdAt

Example using Prisma (for illustration):

model User {
  id       String @id @default(cuid())
  email    String @unique
  password String?
  // other fields...
  tokens   Token[]
}

model Token {
  id        String   @id @default(cuid())
  user      User?    @relation(fields: [userId], references: [id])
  userId    String?
  email     String
  token     String   @unique
  type      String   // "PASSWORD_RESET" or "MAGIC_LINK"
  expiresAt DateTime
  usedAt    DateTime?
  createdAt DateTime @default(now())
}

For a minimal setup, you can store tokens in any database of your choice; the pattern remains the same.


Step 3: Generate secure tokens

Use a cryptographically secure random token. In Node/Next.js, you can use crypto:

// lib/tokens.ts
import crypto from 'crypto';

export function generateToken(length = 32) {
  return crypto.randomBytes(length).toString('hex');
}

// Example: 64-character hex token (256 bits)

You’ll use this to create tokens for both password resets and magic links.


Step 4: Create a shared email sender using Resend

To avoid duplication, create a helper to send emails using Resend. You can send plain HTML or use React email components.

Basic example:

// lib/email-actions.ts
import { resend, EMAIL_FROM, APP_URL } from './resend';
import { generateToken } from './tokens';

type TokenType = 'PASSWORD_RESET' | 'MAGIC_LINK';

interface CreateTokenParams {
  email: string;
  type: TokenType;
}

// TODO: Replace with your DB client (e.g. Prisma, Drizzle, etc.)
async function saveTokenToDb(email: string, token: string, type: TokenType) {
  const expiresAt = new Date(Date.now() + 1000 * 60 * 15); // 15 minutes

  // Example with Prisma:
  // await prisma.token.create({
  //   data: { email, token, type, expiresAt },
  // });

  return { token, expiresAt };
}

export async function sendPasswordResetEmail(email: string) {
  const token = generateToken();
  await saveTokenToDb(email, token, 'PASSWORD_RESET');

  const resetUrl = `${APP_URL}/reset-password?token=${encodeURIComponent(token)}`;

  await resend.emails.send({
    from: EMAIL_FROM,
    to: email,
    subject: 'Reset your password',
    html: `
      <p>Click the link below to reset your password:</p>
      <p><a href="${resetUrl}">${resetUrl}</a></p>
      <p>This link will expire in 15 minutes. If you didn’t request this, you can ignore this email.</p>
    `,
  });
}

export async function sendMagicLinkEmail(email: string) {
  const token = generateToken();
  await saveTokenToDb(email, token, 'MAGIC_LINK');

  const magicUrl = `${APP_URL}/magic-login?token=${encodeURIComponent(token)}`;

  await resend.emails.send({
    from: EMAIL_FROM,
    to: email,
    subject: 'Log in to your account',
    html: `
      <p>Click the link below to log in:</p>
      <p><a href="${magicUrl}">${magicUrl}</a></p>
      <p>This link will expire in 15 minutes and can only be used once.</p>
    `,
  });
}

In a production app, replace the saveTokenToDb placeholder with your actual DB operations and ensure that:

  • Old tokens are cleaned up periodically.
  • Only one active token per user per type is allowed (optional but recommended).

Step 5: Create API routes (App Router) to request emails

With the Next.js App Router, API routes live under app/api. Let’s create:

  • app/api/auth/request-password-reset/route.ts
  • app/api/auth/request-magic-link/route.ts

Password reset request route

// app/api/auth/request-password-reset/route.ts
import { NextResponse } from 'next/server';
import { sendPasswordResetEmail } from '@/lib/email-actions';

// TODO: Replace with your actual DB lookup
async function findUserByEmail(email: string) {
  // Example: return await prisma.user.findUnique({ where: { email } });
  return { id: 'dummy', email }; // Replace with real lookup
}

export async function POST(req: Request) {
  try {
    const { email } = await req.json();

    if (!email || typeof email !== 'string') {
      return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
    }

    const user = await findUserByEmail(email);

    // To prevent user enumeration, respond with success even if user doesn't exist
    if (user) {
      await sendPasswordResetEmail(email);
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Request password reset error:', error);
    return NextResponse.json(
      { error: 'Something went wrong' },
      { status: 500 }
    );
  }
}

Magic link request route

// app/api/auth/request-magic-link/route.ts
import { NextResponse } from 'next/server';
import { sendMagicLinkEmail } from '@/lib/email-actions';

// TODO: Replace with your actual DB lookup
async function findUserByEmail(email: string) {
  // Example: return await prisma.user.findUnique({ where: { email } });
  return { id: 'dummy', email }; // Replace with real lookup
}

export async function POST(req: Request) {
  try {
    const { email } = await req.json();

    if (!email || typeof email !== 'string') {
      return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
    }

    const user = await findUserByEmail(email);

    // Optionally: auto-create users for magic link flow
    if (!user) {
      // create user or silently ignore
      // await prisma.user.create({ data: { email } });
    }

    await sendMagicLinkEmail(email);

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Request magic link error:', error);
    return NextResponse.json(
      { error: 'Something went wrong' },
      { status: 500 }
    );
  }
}

These routes can be called from your client-side forms using fetch or a mutation hook.


Step 6: Build the password reset page and API handler

You need two pieces:

  1. A page at /reset-password that:
    • Reads the token from the query string.
    • Shows a form to set a new password.
  2. A server action or API route that:
    • Validates the token (exists, not expired, not used).
    • Updates the user password.
    • Marks the token as used.

Password reset page (App Router)

// app/reset-password/page.tsx
'use client';

import { useSearchParams, useRouter } from 'next/navigation';
import { useState } from 'react';

export default function ResetPasswordPage() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const token = searchParams.get('token');

  const [password, setPassword] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);

  if (!token) {
    return <p>Invalid or missing token.</p>;
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setStatus('loading');
    setError(null);

    try {
      const res = await fetch('/api/auth/reset-password', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, password }),
      });

      const data = await res.json();

      if (!res.ok) {
        throw new Error(data.error || 'Failed to reset password');
      }

      setStatus('success');
      // Optionally redirect after a short delay
      setTimeout(() => router.push('/login'), 2000);
    } catch (err: any) {
      setStatus('error');
      setError(err.message);
    }
  };

  return (
    <main className="max-w-md mx-auto py-8">
      <h2 className="text-xl font-semibold mb-4">Reset your password</h2>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block mb-1 text-sm font-medium">New password</label>
          <input
            type="password"
            className="w-full border rounded px-3 py-2"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            minLength={8}
          />
        </div>
        <button
          type="submit"
          disabled={status === 'loading'}
          className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {status === 'loading' ? 'Resetting...' : 'Reset password'}
        </button>
      </form>
      {status === 'success' && (
        <p className="mt-4 text-green-600">Password updated! Redirecting…</p>
      )}
      {status === 'error' && error && (
        <p className="mt-4 text-red-600">{error}</p>
      )}
    </main>
  );
}

Password reset API route

// app/api/auth/reset-password/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

// TODO: Replace with your DB client
// import { prisma } from '@/lib/prisma';

async function findToken(token: string, type: string) {
  // Example with Prisma:
  // return prisma.token.findUnique({ where: { token }, include: { user: true } });
  return null; // Replace with real lookup
}

async function markTokenUsed(id: string) {
  // Example:
  // await prisma.token.update({ where: { id }, data: { usedAt: new Date() } });
}

async function updateUserPassword(userId: string, password: string) {
  // Hash password (e.g., bcrypt) and update user
  const hashed = crypto.createHash('sha256').update(password).digest('hex');
  // Example:
  // await prisma.user.update({ where: { id: userId }, data: { password: hashed } });
}

export async function POST(req: Request) {
  try {
    const { token, password } = await req.json();

    if (!token || typeof token !== 'string' || !password || typeof password !== 'string') {
      return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
    }

    const dbToken: any = await findToken(token, 'PASSWORD_RESET');

    if (!dbToken) {
      return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
    }

    if (dbToken.usedAt) {
      return NextResponse.json({ error: 'Token already used' }, { status: 400 });
    }

    if (new Date(dbToken.expiresAt) < new Date()) {
      return NextResponse.json({ error: 'Token expired' }, { status: 400 });
    }

    if (!dbToken.userId) {
      return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
    }

    await updateUserPassword(dbToken.userId, password);
    await markTokenUsed(dbToken.id);

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Reset password error:', error);
    return NextResponse.json({ error: 'Server error' }, { status: 500 });
  }
}

Replace the placeholder DB functions with your actual database logic and use a real password hashing algorithm (e.g., bcrypt or argon2) instead of crypto.createHash.


Step 7: Build the magic link callback route

For magic links, you’ll usually:

  • Validate the token (similar to password reset).
  • Mark it as used.
  • Create a session or cookie.
  • Redirect the user to a logged-in page (e.g., /dashboard).

Magic login page (token handler)

You can implement this as a server component that runs on the server, validates the token, sets cookies, and redirects.

// app/magic-login/page.tsx
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';

// TODO: Replace with your DB and session logic
// import { prisma } from '@/lib/prisma';

async function validateMagicToken(token: string) {
  // Example:
  // const dbToken = await prisma.token.findUnique({
  //   where: { token },
  //   include: { user: true },
  // });
  // return dbToken;
  return null; // Replace with real lookup
}

async function markTokenUsed(id: string) {
  // await prisma.token.update({ where: { id }, data: { usedAt: new Date() } });
}

export default async function MagicLoginPage({
  searchParams,
}: {
  searchParams: { token?: string };
}) {
  const token = searchParams.token;

  if (!token) {
    // Optionally show an error or redirect
    redirect('/login?error=missing_token');
  }

  const dbToken: any = await validateMagicToken(token);

  if (
    !dbToken ||
    dbToken.type !== 'MAGIC_LINK' ||
    dbToken.usedAt ||
    new Date(dbToken.expiresAt) < new Date()
  ) {
    redirect('/login?error=invalid_or_expired_link');
  }

  if (!dbToken.userId) {
    redirect('/login?error=invalid_user');
  }

  // Create a session. This is pseudo-code; use your auth/session library.
  const sessionToken = `session-${dbToken.userId}-${Date.now()}`;
  cookies().set('session', sessionToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    path: '/',
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });

  await markTokenUsed(dbToken.id);

  // Redirect to dashboard or any internal page
  redirect('/dashboard');
}

In a real app, integrate with a session/auth library (NextAuth, Lucia, custom JWTs, etc.) rather than a raw session cookie, but this shows where the magic happens.


Step 8: Frontend forms for password reset and magic link requests

Finally, build simple forms that call the API routes.

Password reset request form (e.g., /forgot-password)

// app/forgot-password/page.tsx
'use client';

import { useState } from 'react';

export default function ForgotPasswordPage() {
  const [email, setEmail] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'sent' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setStatus('loading');
    setError(null);

    try {
      const res = await fetch('/api/auth/request-password-reset', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });

      if (!res.ok) {
        throw new Error('Failed to send reset email');
      }

      setStatus('sent');
    } catch (err: any) {
      setStatus('error');
      setError(err.message);
    }
  };

  return (
    <main className="max-w-md mx-auto py-8">
      <h2 className="text-xl font-semibold mb-4">Forgot your password?</h2>
      <p className="mb-4 text-sm text-gray-600">
        Enter your email and we’ll send you a link to reset your password.
      </p>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block mb-1 text-sm font-medium">Email</label>
          <input
            type="email"
            className="w-full border rounded px-3 py-2"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>
        <button
          type="submit"
          disabled={status === 'loading'}
          className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {status === 'loading' ? 'Sending...' : 'Send reset link'}
        </button>
      </form>
      {status === 'sent' && (
        <p className="mt-4 text-green-600">
          If an account exists for that email, we’ve sent a password reset link.
        </p>
      )}
      {status === 'error' && error && (
        <p className="mt-4 text-red-600">{error}</p>
      )}
    </main>
  );
}

Magic link login form (e.g., /magic-login-request)

// app/magic-login-request/page.tsx
'use client';

import { useState } from 'react';

export default function MagicLoginRequestPage() {
  const [email, setEmail] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'sent' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setStatus('loading');
    setError(null);

    try {
      const res = await fetch('/api/auth/request-magic-link', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });

      if (!res.ok) {
        throw new Error('Failed to send magic link');
      }

      setStatus('sent');
    } catch (err: any) {
      setStatus('error');
      setError(err.message);
    }
  };

  return (
    <main className="max-w-md mx-auto py-8">
      <h2 className="text-xl font-semibold mb-4">Log in with a magic link</h2>
      <p className="mb-4 text-sm text-gray-600">
        Enter your email and we’ll send you a one-time login link.
      </p>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block mb-1 text-sm font-medium">Email</label>
          <input
            type="email"
            className="w-full border rounded px-3 py-2"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>
        <button
          type="submit"
          disabled={status === 'loading'}
          className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {status === 'loading' ? 'Sending...' : 'Send magic link'}
        </button>
      </form>
      {status === 'sent' && (
        <p className="mt-4 text-green-600">
          If an account exists for that email, we’ve sent a magic login link.
        </p>
      )}
      {status === 'error' && error && (
        <p className="mt-4 text-red-600">{error}</p>
      )}
    </main>
  );
}

Security best practices for password reset and magic link emails

When you integrate Resend with Next.js (App Router) for password reset and magic link emails, keep these security details in mind:

  • Short expiration: 10–30 minutes is typical for these tokens.
  • One-time usage: Mark tokens as used after the first successful request.
  • No user enumeration: API responses should not reveal whether an email exists.
  • Strong randomness: Use crypto.randomBytes or a similar cryptographically secure generator.
  • HTTPS only: Ensure your APP_URL uses https in production so links are secure.
  • Rate limiting: Consider throttling requests to /request-password-reset and /request-magic-link.
  • Email content: Clearly state why the user received the email and what to do if they didn’t initiate the request.

Troubleshooting common issues

When wiring up Resend with the Next.js App Router for password reset and magic link flows, developers often run into a few repeated issues:

  • Emails not sending

    • Check RESEND_API_KEY and EMAIL_FROM in .env.local.
    • Ensure you’re using a verified sending domain in Resend.
    • Log the result of resend.emails.send to see any errors.
  • Links not working (404 or wrong domain)

    • Verify APP_URL and that routes like /reset-password and /magic-login exist.
    • Ensure you URL-encode tokens with encodeURIComponent.
  • Token always invalid or expired

    • Confirm you’re saving the token exactly as generated.
    • Check that your database time and server time are in sync.
    • Make sure you filter by type (PASSWORD_RESET vs MAGIC_LINK) if you store both in the same table.
  • Session not persisting after magic link

    • Double-check cookie options (domain, path, secure, httpOnly).
    • Make sure the cookie is set in a server component (like the example) or in a server route, not from the client.

By following these steps, you can integrate Resend with Next.js (App Router) for password reset and magic link emails in a clean, modular way that’s ready for production. Swap in your own database, password hashing, and session management, and you’ll have a modern, secure email-based authentication flow tailored to your app.