Back to blog
Next.jsTutorialIntegration

How to Send Emails from Next.js (App Router)

A practical guide to sending transactional emails from Next.js using the SendPigeon SDK. Password resets, welcome emails, templates, and best practices.

SendPigeon TeamDecember 20, 20256 min read

Need to send emails from your Next.js app? This guide covers Server Actions, API routes, and best practices using the SendPigeon SDK.

TL;DR

Quick setup:

  1. npm install sendpigeon
  2. Add SENDPIGEON_API_KEY to .env.local
  3. Use Server Actions or API routes to send

Best for: Password resets, welcome emails, order confirmations, notifications.


Installation

npm install sendpigeon

Setup

Store your API key in .env.local:

SENDPIGEON_API_KEY=sp_live_your_key_here

Create a shared client:

// lib/email.ts
import { SendPigeon } from "sendpigeon";

export const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);

Using Server Actions

Server Actions are the cleanest way to send emails in Next.js 14+. They run on the server, keeping your API key safe.

Password Reset

// app/actions/auth.ts
"use server";

import { pigeon } from "@/lib/email";

export async function requestPasswordReset(formData: FormData) {
  const email = formData.get("email") as string;

  // Generate reset token (your auth logic)
  const token = crypto.randomUUID();
  const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`;

  await pigeon.send({
    from: "auth@yourdomain.com",
    to: email,
    subject: "Reset your password",
    html: `
      <h1>Password Reset</h1>
      <p>Click the link below to reset your password:</p>
      <a href="${resetUrl}">Reset Password</a>
      <p>This link expires in 1 hour.</p>
    `,
  });

  return { success: true };
}

Use in your form:

// app/forgot-password/page.tsx
import { requestPasswordReset } from "@/app/actions/auth";

export default function ForgotPasswordPage() {
  return (
    <form action={requestPasswordReset}>
      <input type="email" name="email" placeholder="your@email.com" required />
      <button type="submit">Send Reset Link</button>
    </form>
  );
}

Welcome Email on Signup

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

import { pigeon } from "@/lib/email";

export async function signupUser(formData: FormData) {
  const email = formData.get("email") as string;
  const name = formData.get("name") as string;

  // Create user in database first
  const user = await db.user.create({ data: { email, name } });

  // Send welcome email
  await pigeon.send({
    from: "hello@yourdomain.com",
    to: email,
    subject: `Welcome, ${name}!`,
    html: `
      <h1>Welcome to MyApp!</h1>
      <p>Thanks for signing up. Here's what you can do next:</p>
      <ul>
        <li>Complete your profile</li>
        <li>Explore the dashboard</li>
        <li>Invite your team</li>
      </ul>
    `,
  });

  return { success: true, userId: user.id };
}

Using Templates

For cleaner code, use stored templates with variables:

// Create template once (or via dashboard)
const template = await pigeon.templates.create({
  name: "welcome-email",
  subject: "Welcome, {{name}}!",
  html: `
    <h1>Welcome to MyApp, {{name}}!</h1>
    <p>Your account is ready at {{email}}.</p>
    <a href="{{dashboardUrl}}">Go to Dashboard</a>
  `,
});

console.log(template.id); // "tpl_abc123" - use this when sending

// Use in your action (with the template ID, not name)
await pigeon.send({
  from: "hello@yourdomain.com",
  to: email,
  templateId: template.id, // or "tpl_abc123"
  variables: {
    name: "Johan",
    email: "johan@example.com",
    dashboardUrl: "https://myapp.com/dashboard",
  },
});

Store template IDs as constants or environment variables for reuse.


Using React Email

For type-safe, component-based emails, use React Email. Build emails with React, render to HTML, then send.

npm install @react-email/components @react-email/render

Create a reusable email component:

// emails/welcome.tsx
import { Html, Head, Body, Container, Text, Button } from "@react-email/components";

type WelcomeEmailProps = {
  name: string;
  dashboardUrl: string;
};

export function WelcomeEmail({ name, dashboardUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: "sans-serif" }}>
        <Container>
          <Text>Welcome, {name}!</Text>
          <Text>Your account is ready. Click below to get started.</Text>
          <Button href={dashboardUrl}>Go to Dashboard</Button>
        </Container>
      </Body>
    </Html>
  );
}

Render and send in your Server Action:

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

import { render } from "@react-email/render";
import { pigeon } from "@/lib/email";
import { WelcomeEmail } from "@/emails/welcome";

export async function signupUser(formData: FormData) {
  const email = formData.get("email") as string;
  const name = formData.get("name") as string;

  const user = await db.user.create({ data: { email, name } });

  const html = await render(
    <WelcomeEmail name={name} dashboardUrl="https://myapp.com/dashboard" />
  );

  await pigeon.send({
    from: "hello@yourdomain.com",
    to: email,
    subject: `Welcome, ${name}!`,
    html,
  });

  return { success: true, userId: user.id };
}

React Email gives you type-safe props, component reuse, and live preview during development. Run npx react-email dev to preview your emails.


Using API Routes

For webhooks or client-triggered emails:

// app/api/send-invite/route.ts
import { pigeon } from "@/lib/email";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const { email, inviterName, teamName } = await request.json();

  const { data, error } = await pigeon.send({
    from: "invites@yourdomain.com",
    to: email,
    subject: `${inviterName} invited you to ${teamName}`,
    html: `<p>You've been invited to join ${teamName}. Click below to accept.</p>`,
  });

  if (error) {
    return NextResponse.json({ error: error.message }, { status: error.status });
  }

  return NextResponse.json({ emailId: data.id });
}

Error Handling

The SDK returns { data, error } instead of throwing, making error handling straightforward:

import { pigeon } from "@/lib/email";

export async function sendWelcomeEmail(email: string) {
  const { data, error } = await pigeon.send({
    from: "hello@yourdomain.com",
    to: email,
    subject: "Welcome!",
    html: "<h1>Welcome!</h1>",
  });

  if (error) {
    console.error(`Email failed: ${error.message} (${error.status})`);

    // Handle specific errors
    if (error.status === 429) {
      // Rate limited - queue for retry
    }
    return { success: false, error: error.message };
  }

  return { success: true, emailId: data.id };
}

Non-Blocking Emails

Don't let email failures break critical flows

Create the user first, then send the email. Log failures but don't fail the signup.

export async function signupUser(formData: FormData) {
  const email = formData.get("email") as string;

  // Critical: create user first
  const user = await db.user.create({ data: { email } });

  // Non-critical: send welcome email
  const { error } = await pigeon.send({
    from: "hello@yourdomain.com",
    to: email,
    subject: "Welcome!",
    html: "<h1>Welcome!</h1>",
  });

  if (error) {
    // Log but don't fail signup
    console.error("Welcome email failed:", error.message);
  }

  return { success: true, userId: user.id };
}

Environment Setup

# .env.local (don't commit)
SENDPIGEON_API_KEY=sp_live_xxx

# .env.example (commit this)
SENDPIGEON_API_KEY=

Add .env.local to .gitignore. Never commit API keys.


Full Example: Contact Form

// app/actions/contact.ts
"use server";

import { pigeon } from "@/lib/email";
import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  message: z.string().min(10),
});

export async function submitContact(formData: FormData) {
  const data = contactSchema.parse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  // Notify yourself
  await pigeon.send({
    from: "contact@yourdomain.com",
    to: "you@yourdomain.com",
    subject: `Contact form: ${data.name}`,
    html: `
      <p><strong>From:</strong> ${data.name} (${data.email})</p>
      <p><strong>Message:</strong></p>
      <p>${data.message}</p>
    `,
    replyTo: data.email,
  });

  // Confirm to sender
  await pigeon.send({
    from: "contact@yourdomain.com",
    to: data.email,
    subject: "We received your message",
    html: `<p>Thanks for reaching out, ${data.name}. We'll get back to you soon.</p>`,
  });

  return { success: true };
}

Next Steps


Other Frameworks

Using a different framework? We have guides for:

See all framework guides.