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?

9 min read

Integrating Resend with a Next.js App Router project is a clean way to send password reset and magic link emails without maintaining your own SMTP infrastructure. With the App Router (app/ directory), you can expose secure server-side email endpoints and keep your authentication flows fully typed and maintainable.

Below is a step‑by‑step guide to help you understand how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l, from environment configuration to sending real emails from your Next.js app.


1. Prerequisites

Before you start, make sure you have:

  • A Next.js 13+ project using the App Router (app/ directory)
  • Node.js 18+ (recommended)
  • A Resend account and API key
  • Basic familiarity with:
    • Server Components vs Client Components
    • Route handlers in app/api/*/route.ts
    • Next.js environment variables

If you don’t have Resend set up yet, go to https://resend.com, create a project, and grab your API key from the dashboard.


2. Install and Configure Resend in Next.js

First, install the Resend SDK:

npm install resend
# or
yarn add resend

Add your Resend API key to your environment variables:

# .env.local
RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM="Your App <no-reply@yourdomain.com>"
NEXT_PUBLIC_APP_URL=https://your-app-url.com

Use NEXT_PUBLIC_APP_URL so you can reliably build absolute URLs for password reset and magic links.

Create a reusable Resend client in your project, for example:

// 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);

3. Designing the Auth Flow: Tokens & Links

For both password reset and magic link flows, you need a secure token system and a way to generate URLs that users can click from their email.

3.1. Common token requirements

  • Random and unique (e.g., crypto.randomUUID() or crypto.randomBytes)
  • Short expiry (e.g., 15–30 minutes)
  • Stored server-side:
    • In a database (e.g., PostgreSQL, Prisma, Mongo)
    • Or a key-value store (e.g., Redis, Upstash, KV)
  • Tied to:
    • The user’s email
    • The token type (e.g., "reset_password", "magic_link")
    • Expiration timestamp

A simple Prisma schema example:

model AuthToken {
  id        String   @id @default(cuid())
  token     String   @unique
  email     String
  type      String   // 'reset_password' | 'magic_link'
  expiresAt DateTime
  used      Boolean  @default(false)
  createdAt DateTime @default(now())
}

3.2. Generating a secure token

In a Next.js server environment, you can do something like:

// lib/tokens.ts
import crypto from 'crypto';
import { prisma } from './prisma';

type TokenType = 'reset_password' | 'magic_link';

export async function createAuthToken(email: string, type: TokenType) {
  const token = crypto.randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 1000 * 60 * 15); // 15 minutes

  await prisma.authToken.create({
    data: {
      token,
      email,
      type,
      expiresAt,
    },
  });

  return token;
}

export async function useAuthToken(token: string, type: TokenType) {
  const record = await prisma.authToken.findUnique({ where: { token } });

  if (!record || record.type !== type || record.used || record.expiresAt < new Date()) {
    return null;
  }

  await prisma.authToken.update({
    where: { token },
    data: { used: true },
  });

  return record;
}

4. Sending a Password Reset Email with Resend

The password reset flow typically has two parts:

  1. An API endpoint to request a reset (sends the email)
  2. A page where the user lands to set a new password (uses the token)

4.1. Password reset request endpoint (App Router)

Create a route handler:

// app/api/auth/password-reset/request/route.ts
import { NextResponse } from 'next/server';
import { resend } from '@/lib/resend';
import { createAuthToken } from '@/lib/tokens';
// import { prisma } from '@/lib/prisma'; // if you need to verify user exists

const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const EMAIL_FROM = process.env.EMAIL_FROM!;

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

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

  // Optionally check if the user exists:
  // const user = await prisma.user.findUnique({ where: { email } });
  // if (!user) { return NextResponse.json({ success: true }); }

  const token = await createAuthToken(email, 'reset_password');

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

  try {
    await resend.emails.send({
      from: EMAIL_FROM,
      to: email,
      subject: 'Reset your password',
      html: `
        <p>You requested a password reset.</p>
        <p>Click the link below to set a new password (valid for 15 minutes):</p>
        <p><a href="${resetUrl}">${resetUrl}</a></p>
        <p>If you did not request this, you can safely ignore this email.</p>
      `,
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Resend error:', error);
    return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
  }
}

This is the core of how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l when you want users to trigger a reset email from your frontend.

4.2. Password reset page (App Router)

Create a page that reads the token from the URL and lets the user submit a new password.

// app/reset-password/page.tsx
import ResetPasswordForm from './ResetPasswordForm';

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

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

  return <ResetPasswordForm token={token} />;
}

An example client component form:

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

import { useState } from 'react';

export default function ResetPasswordForm({ token }: { token: string }) {
  const [password, setPassword] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setStatus('loading');

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

    if (res.ok) {
      setStatus('success');
    } else {
      setStatus('error');
    }
  }

  if (status === 'success') return <p>Password updated successfully.</p>;

  return (
    <form onSubmit={handleSubmit}>
      <label className="block mb-2">
        New password
        <input
          type="password"
          className="border px-2 py-1 w-full"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </label>
      <button
        type="submit"
        disabled={status === 'loading'}
        className="bg-blue-600 text-white px-4 py-2 rounded"
      >
        {status === 'loading' ? 'Updating…' : 'Update password'}
      </button>
      {status === 'error' && <p className="text-red-600 mt-2">Failed to update password.</p>}
    </form>
  );
}

Confirm route handler:

// app/api/auth/password-reset/confirm/route.ts
import { NextResponse } from 'next/server';
import { useAuthToken } from '@/lib/tokens';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';

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

  if (!token || !password) {
    return NextResponse.json({ error: 'Missing data' }, { status: 400 });
  }

  const record = await useAuthToken(token, 'reset_password');
  if (!record) {
    return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 });
  }

  const hashed = await bcrypt.hash(password, 12);

  await prisma.user.update({
    where: { email: record.email },
    data: { passwordHash: hashed },
  });

  return NextResponse.json({ success: true });
}

5. Sending Magic Link Emails with Resend

Magic link login works similarly, but instead of resetting a password you log the user in when they click the link.

5.1. Magic link request endpoint

// app/api/auth/magic-link/request/route.ts
import { NextResponse } from 'next/server';
import { resend } from '@/lib/resend';
import { createAuthToken } from '@/lib/tokens';
import { prisma } from '@/lib/prisma';

const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const EMAIL_FROM = process.env.EMAIL_FROM!;

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

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

  // Ensure the user exists, or create them (depending on your flow)
  let user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    // Optional: auto-register user
    user = await prisma.user.create({ data: { email } });
  }

  const token = await createAuthToken(email, 'magic_link');
  const magicUrl = `${APP_URL}/auth/magic?token=${encodeURIComponent(token)}`;

  try {
    await resend.emails.send({
      from: EMAIL_FROM,
      to: email,
      subject: 'Your magic login link',
      html: `
        <p>Click the link below to log in:</p>
        <p><a href="${magicUrl}">${magicUrl}</a></p>
        <p>This link is valid for 15 minutes and can only be used once.</p>
      `,
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Resend error:', error);
    return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
  }
}

This aligns directly with how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l when magic links are involved.

5.2. Magic link verification route (App Router)

Create a page used only for the redirect logic:

// app/auth/magic/page.tsx
import { redirect } from 'next/navigation';
import { useAuthToken } from '@/lib/tokens';
import { createSession } from '@/lib/session';

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

  if (!token) {
    redirect('/login?error=invalid_magic_link');
  }

  const record = await useAuthToken(token, 'magic_link');
  if (!record) {
    redirect('/login?error=invalid_or_expired_magic_link');
  }

  // Create a session for the user
  await createSession(record.email);

  redirect('/dashboard'); // or wherever you want to send the user after login
}

You’ll need a session helper that sets cookies:

// lib/session.ts
import { cookies } from 'next/headers';
import { prisma } from './prisma';
import crypto from 'crypto';

export async function createSession(email: string) {
  const token = crypto.randomBytes(32).toString('hex');

  await prisma.session.create({
    data: {
      token,
      email,
      // add expiry, etc.
    },
  });

  cookies().set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: '/',
  });
}

6. Using React Email (Optional) for Better Templates

Resend works well with React Email to build JSX-powered email templates.

Install React Email:

npm install react-email

Create a template:

// emails/ResetPasswordEmail.tsx
import * as React from 'react';

export function ResetPasswordEmail({ resetUrl }: { resetUrl: string }) {
  return (
    <div>
      <h1>Reset your password</h1>
      <p>Click the link below to reset your password:</p>
      <p>
        <a href={resetUrl}>{resetUrl}</a>
      </p>
      <p>If you didn’t request this, you can ignore this email.</p>
    </div>
  );
}

Use it with Resend:

// app/api/auth/password-reset/request/route.ts
import { render } from '@react-email/render';
import { ResetPasswordEmail } from '@/emails/ResetPasswordEmail';

// inside try block
await resend.emails.send({
  from: EMAIL_FROM,
  to: email,
  subject: 'Reset your password',
  html: render(<ResetPasswordEmail resetUrl={resetUrl} />),
});

React Email gives you a more maintainable and testable way to manage multiple templates for password reset and magic link flows.


7. Securing Your Resend + Next.js Integration

To keep how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l secure and robust:

  • Never expose RESEND_API_KEY to the client; only use it in server code and route handlers.
  • Validate input on all API routes (email, token, password).
  • Use HTTPS in production so tokens and sessions are not exposed.
  • Add rate limiting for email request endpoints to avoid abuse.
  • Avoid leaking whether a user exists by always returning generic success responses for password reset requests.
  • Keep token lifetimes short and single-use.
  • Log errors from Resend, but don’t expose them in API responses.

8. Putting It All Together

To summarize how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l:

  1. Set up Resend: Install the SDK, configure RESEND_API_KEY, and create a shared client.
  2. Build token logic: Create secure, expiring tokens stored in your database or KV.
  3. Create route handlers:
    • /api/auth/password-reset/request to send reset emails.
    • /api/auth/password-reset/confirm to update the password.
    • /api/auth/magic-link/request to send magic login links.
  4. Build UI pages:
    • /reset-password page for submitting the new password.
    • /auth/magic page to verify the magic link and create a session.
  5. Optionally use React Email for rich, reusable templates.
  6. Harden security: Rate limit, validate inputs, hide user existence, and secure sessions.

With this structure, you get a clean Next.js App Router integration, leveraging Resend for reliable password reset and magic link emails while keeping your auth logic in one coherent, typesafe place.