Back to blog
/8 min read

How to Send Transactional Email from Next.js (App Router)

Step-by-step guide to sending transactional email from a Next.js 14 App Router app using ZidiMail. Covers Route Handlers, Server Actions, environment variables, and error handling.

Next.js App Router runs server-side code either in Route Handlers or Server Actions. Both are great places to call the ZidiMail API — the API key stays on the server and never reaches the browser.

This guide covers the two most common patterns: a Route Handler that your frontend fetches, and a Server Action called directly from a form.

Prerequisites

  • A ZidiMail account (free, no credit card)
  • A verified sending domain on ZidiMail
  • An API key from the ZidiMail dashboard
  • Next.js 14 or later with App Router

1. Add your API key to environment variables

Create or update .env.local in your project root. Never prefix this with NEXT_PUBLIC_ — that would expose it to the browser.

# .env.local
ZIDIMAIL_API_KEY=zm_live_your_full_key
ZIDIMAIL_FROM=hello@mail.yourdomain.com

2. Create a reusable send helper

Add a single utility so you are not duplicating fetch logic across routes. Create lib/email.ts:

// lib/email.ts
const API = 'https://api.zidimails.com/v1/emails'

export type SendOptions = {
  to: string | string[]
  subject: string
  html?: string
  text?: string
  replyTo?: string
}

export async function sendEmail(opts: SendOptions) {
  const res = await fetch(API, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ZIDIMAIL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: process.env.ZIDIMAIL_FROM,
      ...opts,
    }),
  })

  if (!res.ok) {
    const err = await res.json().catch(() => ({}))
    throw new Error(err.error ?? `ZidiMail error ${res.status}`)
  }

  return res.json() as Promise<{ id: string }>
}

3. Route Handler — welcome email on signup

Route Handlers live in app/api/**/route.ts. This example fires a welcome email when a user registers.

// app/api/auth/register/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { sendEmail } from '@/lib/email'

export async function POST(req: NextRequest) {
  const { name, email, password } = await req.json()

  // ... create user in your DB ...

  await sendEmail({
    to: email,
    replyTo: 'support@yourdomain.com',
    subject: `Welcome to Acme, ${name}!`,
    html: `
      <h1>Welcome, ${name}</h1>
      <p>Your account is ready. <a href="https://app.yourdomain.com">Sign in</a> to get started.</p>
    `,
    text: `Welcome, ${name}. Sign in at https://app.yourdomain.com`,
  })

  return NextResponse.json({ ok: true })
}

4. Server Action — password reset

Server Actions let you call server code directly from a form without writing a separate API route. This is ideal for password reset flows where you want the request to feel instant.

// app/forgot-password/actions.ts
'use server'

import { sendEmail } from '@/lib/email'
import { generateResetToken } from '@/lib/auth'  // your token logic

export async function requestPasswordReset(formData: FormData) {
  const email = formData.get('email') as string
  if (!email) return { error: 'Email is required' }

  const token = await generateResetToken(email)
  if (!token) return { ok: true }  // always return ok — don't reveal if email exists

  await sendEmail({
    to: email,
    replyTo: 'noreply@yourdomain.com',
    subject: 'Reset your password',
    html: `
      <p>Click the link below to reset your password. It expires in 1 hour.</p>
      <p><a href="https://app.yourdomain.com/reset-password?token=${token}">Reset password</a></p>
      <p>If you did not request this, ignore this email.</p>
    `,
    text: `Reset your password: https://app.yourdomain.com/reset-password?token=${token}`,
  })

  return { ok: true }
}
// app/forgot-password/page.tsx
import { requestPasswordReset } from './actions'

export default function ForgotPasswordPage() {
  return (
    <form action={requestPasswordReset}>
      <input type="email" name="email" placeholder="you@example.com" required />
      <button type="submit">Send reset link</button>
    </form>
  )
}

5. Test with sandbox mode

New ZidiMail accounts start in sandbox mode — emails only reach the owner email address. This means you can run your full Next.js flow in development without accidentally emailing real users. Once your integration is working, request live access from the ZidiMail dashboard.

6. Error handling and retries

The ZidiMail API is idempotent when you pass an X-Idempotency-Key header. Use this when queuing or retrying sends to prevent duplicates.

await sendEmail({
  to: 'user@example.com',
  subject: 'Order confirmed',
  html: '<p>Your order #1234 is confirmed.</p>',
  // optional — prevents duplicate sends on retry
  headers: { 'X-Idempotency-Key': 'order-1234-confirmed' },
})

Wrap email sends in try/catch and decide whether a failure should block the operation. For welcome emails, log the error and continue — do not fail the signup. For transactional emails like receipts, consider a background queue (e.g. BullMQ or Upstash QStash) to retry on failure.

Common issues

  • "Domain not verified" (422) — your from address domain must be verified in the ZidiMail dashboard under Domains.
  • "Missing or invalid Authorization" (401) — check that ZIDIMAIL_API_KEY is set in .env.local and that you are not prefixing it with NEXT_PUBLIC_.
  • "Recipient suppressed" (422) — the address hard-bounced or complained before. Use a different address for testing.
  • Empty response in edge runtime — ZidiMail uses standard fetch. Edge runtime is supported with no changes needed.

Start sending from Next.js in minutes

Create a free ZidiMail account, verify your domain, and have transactional email working before lunch.

Start sending free