Send email with Next.js
Send transactional email from Next.js through PostStack.
The PostStack TypeScript SDK is a first-class fit for Next.js — fully typed, tree-shakeable, and isomorphic for the bits that need to run server-side. Always call poststack.emails.send(...) from a Route Handler, Server Action, or Server Component, never from a Client Component: shipping your API key to the browser would expose your account to anyone with devtools. The SDK uses the platform fetch, so it runs on Node.js, the Edge Runtime, and Bun-based deploys without changes.
Quickstart#
Install the SDK
bashnpm install @poststack.dev/sdkInitialize the client
typescriptimport { PostStack } from '@poststack.dev/sdk'; const poststack = new PostStack(process.env.POSTSTACK_API_KEY!);Send an email
typescript// app/api/send/route.ts import { NextResponse } from 'next/server'; import { PostStack, PostStackError } from '@poststack.dev/sdk'; const poststack = new PostStack(process.env.POSTSTACK_API_KEY!); export async function POST() { try { const { id } = await poststack.emails.send({ from: 'hello@yourdomain.com', to: ['user@example.com'], subject: 'Hello from Next.js!', html: '<h1>Welcome!</h1>', }); return NextResponse.json({ id }); } catch (err) { if (err instanceof PostStackError) { return NextResponse.json({ error: err.message }, { status: err.statusCode }); } throw err; } }Handle errors
typescriptimport { NextResponse } from 'next/server'; import { PostStack, PostStackError } from '@poststack.dev/sdk'; const poststack = new PostStack(process.env.POSTSTACK_API_KEY!); export async function POST(req: Request) { const body = (await req.json()) as { to: string; subject: string; html: string }; try { const { id } = await poststack.emails.send({ from: 'hello@yourdomain.com', to: [body.to], subject: body.subject, html: body.html, }); return NextResponse.json({ id }); } catch (err) { if (err instanceof PostStackError) { // Forward PostStack's status code: 400 for an invalid payload, 422 for an // unverified domain or suppressed recipient, 429/5xx after the SDK's retries. console.error('PostStack error', { status: err.statusCode, message: err.message, requestId: err.requestId, }); return NextResponse.json({ error: err.message }, { status: err.statusCode }); } throw err; } }
Send email from a Server Action (contact form)#
For a form, a Server Action is shorter than a Route Handler: no fetch from the client and no JSON parsing. Put the client in a server-only module so a Client Component can never import it. The action runs on the server, so process.env.POSTSTACK_API_KEY is available and never reaches the browser.
// lib/poststack.ts
import 'server-only';
import { PostStack } from '@poststack.dev/sdk';
export const poststack = new PostStack(process.env.POSTSTACK_API_KEY!);
// app/contact/actions.ts
'use server';
import { poststack } from '@/lib/poststack';
export async function sendContact(formData: FormData) {
const email = String(formData.get('email'));
const message = String(formData.get('message'));
await poststack.emails.send({
from: 'Acme Support <support@yourdomain.com>', // a verified domain
to: ['support@yourdomain.com'],
reply_to: email, // replies go to the visitor
subject: 'New contact form message',
text: message,
});
}
// app/contact/page.tsx
import { sendContact } from './actions';
export default function ContactPage() {
return (
<form action={sendContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}Email templates with React Email#
Write the email as a React component with @react-email/components, render it to HTML on the server with render(), and pass the string as html. Render a plain-text version too, with { plainText: true }: clients that cannot show HTML use it, and spam filters score it.
// emails/welcome.tsx
import { Body, Button, Html, Text } from '@react-email/components';
export function WelcomeEmail({ name }: { name: string }) {
return (
<Html>
<Body>
<Text>Welcome, {name}!</Text>
<Button href="https://yourdomain.com/start">Get started</Button>
</Body>
</Html>
);
}
// app/api/welcome/route.ts
import { render } from '@react-email/render';
import { poststack } from '@/lib/poststack';
import { WelcomeEmail } from '@/emails/welcome';
export async function POST(req: Request) {
const { email, name } = (await req.json()) as { email: string; name: string };
const html = await render(<WelcomeEmail name={name} />);
const text = await render(<WelcomeEmail name={name} />, { plainText: true });
const { id } = await poststack.emails.send({
from: 'Acme <hello@yourdomain.com>',
to: [email],
subject: 'Welcome to Acme',
html,
text,
});
return Response.json({ id });
}Stored templates with variables#
If the copy is edited in the PostStack dashboard rather than in your repo, send a stored template by id and pass variables. A template can supply the subject and from address, so the call only needs the recipient.
const { id } = await poststack.emails.send({
from: 'Acme <hello@yourdomain.com>',
to: [user.email],
template_id: 'tpl_…', // from the Templates page
variables: { first_name: user.firstName, plan: 'Pro' },
});Send attachments from Next.js#
Attachments are base64 strings. Read the file in a Route Handler or Server Action with Node APIs (runtime = "nodejs", the default), or build the bytes in memory, for example a PDF you just generated. Limits: 10 MB per file and 25 MB per email, measured on the decoded size. Give an image a content_id and reference it as cid: to show it inline.
// app/api/invoice/route.ts
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { poststack } from '@/lib/poststack';
export async function POST(req: Request) {
const { to, invoiceId } = (await req.json()) as { to: string; invoiceId: string };
const pdf = await renderInvoicePdf(invoiceId); // Buffer / Uint8Array
const logo = await readFile(path.join(process.cwd(), 'public/logo.png'));
const { id } = await poststack.emails.send({
from: 'Acme Billing <billing@yourdomain.com>',
to: [to],
subject: `Invoice ${invoiceId}`,
html: '<img src="cid:logo" alt="Acme" width="120"><p>Your invoice is attached.</p>',
attachments: [
{ filename: `invoice-${invoiceId}.pdf`, content: Buffer.from(pdf).toString('base64') },
{ filename: 'logo.png', content: logo.toString('base64'), content_id: 'logo' },
],
});
return Response.json({ id });
}Handle bounces with a webhook Route Handler#
Bounces and spam complaints arrive after the send has returned. Add a webhook endpoint in PostStack for email.bounced and email.complained, read the body with req.text() and verify X-PostStack-Signature before parsing it. req.json() would re-serialise the body and the HMAC would never match. PostStack suppresses the address itself; your handler updates your own data and returns any 2xx. Anything else is retried.
// app/api/webhooks/poststack/route.ts
import { PostStack } from '@poststack.dev/sdk';
export async function POST(req: Request) {
const raw = await req.text(); // exact bytes
const ok = await PostStack.Webhooks.verify(
raw,
req.headers.get('X-PostStack-Signature') ?? '',
process.env.POSTSTACK_WEBHOOK_SECRET!, // whsec_…
);
if (!ok) return new Response('Invalid signature', { status: 401 });
const event = JSON.parse(raw) as {
type: string;
data: { email_id: string; to: string[]; bounce_message?: string };
};
switch (event.type) {
case 'email.bounced':
await db.user.updateMany({ // your ORM here
where: { lastEmailId: event.data.email_id },
data: { emailUndeliverable: true },
});
break;
case 'email.complained':
await unsubscribeEverywhere(event.data.to);
break;
}
return new Response(null, { status: 204 });
}Already using Nodemailer? Point it at the SMTP relay#
An existing Nodemailer setup only needs new SMTP settings. Nodemailer needs Node APIs, so keep it out of runtime = "edge" routes and middleware. For new code the SDK is simpler, and it returns the email id you need to match webhooks.
// lib/mailer.ts
import 'server-only';
import nodemailer from 'nodemailer';
export const mailer = nodemailer.createTransport({
host: 'smtp.poststack.dev',
port: 465,
secure: true, // implicit TLS; or 587 + requireTLS
auth: { user: 'poststack', pass: process.env.POSTSTACK_API_KEY! },
});
await mailer.sendMail({
from: 'Acme <hello@yourdomain.com>',
to: 'user@example.com',
subject: 'Hello over SMTP',
html: '<h1>Welcome!</h1>',
headers: { 'X-PostStack-Tags': 'welcome' },
});Framework integrations#
App Router — Route Handlers
The recommended path. Create app/api/send/route.ts exporting an async POST function, build the SDK client once at module scope, and await poststack.emails.send(...) inside the handler.
App Router — Server Actions
Mark the action with "use server" and call poststack.emails.send(...) directly. Server Actions inherit the request, so you can read cookies, headers, and form data without a separate fetch hop. Use this for form-driven sends (contact, support, signup confirmation).
Edge Runtime
The SDK runs unmodified on the Edge Runtime since it relies only on the platform fetch. Set export const runtime = "edge" in your route file. Edge is great for lower-latency sends from globally distributed users, though the cold-start improvement is small for European-only audiences.
Pages Router (legacy)
Use pages/api/send.ts with a default-exported handler. Same client, same method calls — only the import surface changes (NextApiRequest/NextApiResponse instead of the App Router’s Request/Response).
Common pitfalls#
422 "Domain … is not verified"
The from address must be on a domain you added and verified in PostStack. A placeholder such as onboarding@resend.dev, left over from another provider’s quickstart, is the usual cause. err.message names the domain.
Webhook signature never matches
Verify the body from await req.text(), not await req.json(): parsing and re-serialising changes the bytes. Also check that POSTSTACK_WEBHOOK_SECRET is the endpoint’s full whsec_… value, and that a proxy or middleware does not rewrite the body.
Nodemailer or fs in an Edge route
node:fs and Nodemailer’s sockets do not exist on the Edge Runtime. Keep those routes on the default Node.js runtime. The SDK itself works on both.
Calling from a Client Component
Never call poststack.emails.send(...) in a "use client" component. The API key would ship to the browser and be visible in the bundle. Always route through a server-side handler or Server Action.
Using NEXT_PUBLIC_ for the API key
NEXT_PUBLIC_* variables are exposed to the client. Use a plain POSTSTACK_API_KEY env var — Next.js will keep it server-only.
Reading the key in the browser bundle
process.env.POSTSTACK_API_KEY is only defined on the server. If it is undefined at send time, the code is running client-side — move it into a Route Handler or Server Action. For a shared helper, add import "server-only" at the top so a client import fails the build.
FAQ#
Where should I instantiate the SDK in a Next.js app?
Once, at module scope, in a server-only module (for example lib/poststack.ts with import "server-only") that your Route Handlers and Server Actions import. The client is a lightweight wrapper around fetch holding the key and retry settings, so one instance per server process is all you need.
Does the SDK work on the Edge Runtime?
Yes — it uses the platform fetch and has no Node-only dependencies. Set export const runtime = "edge" on your route to opt into Edge.
Can I send from a Server Action?
Yes. Mark the action with "use server" and call poststack.emails.send(...). Server Actions are the cleanest fit for form-driven flows like contact and signup confirmations.
What about React Email for templates?
Author the template as a React component, render it to an HTML string on the server with await render(<WelcomeEmail name="Ada" />) from @react-email/render, and pass that string as html on the send. The SDK takes HTML, not React elements.
How do I send an attachment from Next.js?
Pass attachments: [{ filename, content }] where content is the file as a base64 string, for example Buffer.from(bytes).toString("base64") in a Route Handler or Server Action. Up to 10 MB per file and 25 MB per email.
How do I know if an email from Next.js bounced?
Create a webhook endpoint for email.bounced and email.complained that points at a Route Handler such as app/api/webhooks/poststack/route.ts, and verify the signature against await req.text(). Store the id returned by emails.send() to match the event to a user.
Reference#
The docs this guide builds on:
- SDKs — every official client, its retries and idempotency keys
- SMTP relay — hosts, ports, TLS modes and the
X-PostStack-*headers - Emails API — every send field, attachments, scheduling and batch sends
- Templates — stored templates with variables
- Webhooks — event types, payloads and signature verification
- Suppressions — what happens to an address after a hard bounce or complaint
Ready to send from Next.js?
Create a free account and an API key — no card needed.