Welcome emails in Next.js: route handler, template, delivery poll
Where the send belongs in an App Router codebase, how to keep it off the signup response path, and how to confirm delivery afterwards without a webhook.
The signup handler should accept the account, return, and let the welcome email happen behind the response. In an App Router app that means a Route Handler or Server Action on the Node runtime, a stored template so the copy isn’t a string literal in your controller, and a separate poll to confirm delivery — because Infrai’s email surface has no outbound webhooks, so delivery state is something you read rather than something you receive.
Three files do the whole job. Infrai’s part is two REST calls with the same key you already use for the rest of your infrastructure; the Next.js part is deciding where those calls run, which is where most tutorials quietly get it wrong.
Where the send goes, and what breaks if you put it elsewhere
| Location | Works? | What goes wrong |
|---|---|---|
Client component ("use client") | no | Your API key ships to the browser the moment you prefix it NEXT_PUBLIC_ |
| Server Action | yes | Fine for a form post; the user waits for the vendor round trip |
Route Handler, runtime = "nodejs" | yes | The recommended spot; full Node APIs, key stays server-side |
| Route Handler on the edge runtime | mostly | fetch works, but Node built-ins don’t; keep crypto and retries simple |
Inside after() | yes | Response returns first, work continues — what you actually want |
| A cron job on a schedule | yes | Right for the delivery poll, wrong for the welcome itself |
Keep it off the critical path.
The signup POST shouldn’t wait on an email vendor. Adding 200–400ms of upstream latency to the one request a new user judges you on is a bad trade, and if the vendor has a slow minute your signup endpoint has a slow minute too.
Store the copy once
Create the template as a one-off — from your terminal, from a seed script, whatever you like. It’s a free call, and the returned template_id becomes an environment variable.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/template/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "welcome-v1",
"subject": "Welcome to {{product}}, {{first_name}}",
"html": "<p>Hi {{first_name}},</p><p>Your {{product}} workspace is ready. <a href=\"{{app_url}}\">Open it here</a>.</p>",
"variables": {"first_name": "string", "product": "string", "app_url": "string"}
}'
{
"ok": true,
"data": {
"template_id": "tmpl_9xKbQ2tVrLmA7dEwPz4N",
"name": "welcome-v1",
"subject": "Welcome to {{product}}, {{first_name}}",
"variables": { "first_name": "string", "product": "string", "app_url": "string" },
"default_vars": null
}
}
Double-brace placeholders are the render syntax. Keeping the markup server-side rather than inline in route.ts means marketing can change a sentence without a deploy — and it means the same copy is reachable from your background workers, not just from Next.js.
The signup route handler
// app/api/signup/route.ts
import { after } from "next/server";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const API = "https://api.infrai.cc";
const TEMPLATE_ID = process.env.INFRAI_WELCOME_TEMPLATE_ID ?? "";
async function infrai(path: string, init: RequestInit = {}) {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const res = await fetch(`${API}${path}`, {
...init,
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
cache: "no-store",
});
const payload = await res.json().catch(() => ({}));
if (!res.ok || payload.ok === false) {
const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`${path} -> ${err.code}: ${err.message}`);
}
return payload.data;
}
export async function POST(request: Request) {
const { email, firstName } = await request.json();
if (typeof email !== "string" || !email.includes("@")) {
return NextResponse.json({ error: "invalid email" }, { status: 400 });
}
// Your own user creation runs here, before the response is built.
const userId = `usr_${Buffer.from(email).toString("hex").slice(0, 12)}`;
after(async () => {
try {
const screen = await infrai(`/v1/email/suppression/check/${encodeURIComponent(email)}`);
if (screen.suppressed) {
console.warn(`welcome skipped for ${userId}: suppressed (${screen.reason})`);
return;
}
const sent = await infrai("/v1/email/send", {
method: "POST",
body: JSON.stringify({
to: email,
from: "hello@mail.example.com",
template_id: TEMPLATE_ID,
template_vars: { first_name: firstName ?? "there", product: "Acme", app_url: "https://app.example.com" },
}),
});
console.log(`welcome queued ${sent.message_id} for ${userId}`);
} catch (err) {
console.error(`welcome failed for ${userId}:`, err);
}
});
return NextResponse.json({ id: userId }, { status: 201 });
}
The suppression screen is the step people skip. An address that hard-bounced or filed a complaint on a previous send is already on the account list, and mailing it again is how a domain’s reputation erodes — the check costs one free call and tells you suppressed, reason and added_at in one shot.
One free call, before you spend a billable one:
curl -sS "https://api.infrai.cc/v1/email/suppression/check/user@example.com" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"email": "user@example.com",
"suppressed": true,
"reason": "manual",
"scope": "account",
"added_at": "2026-07-04T17:02:22.803322Z",
"attempt_count_blocked": 0
}
}
Note that email.send enforces the list anyway — a suppressed address comes back in suppressed_recipients with nothing dispatched. Screening first just lets you record why the user never got mail, in your own database, next to the user row.
Confirming delivery, from somewhere that isn’t the request
Serverless functions stop executing once the response is flushed, so a setTimeout poll inside the signup handler is a coin flip. Put the poll on a schedule instead and read the per-message timeline.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_jiAQ671ekGVqfGXj1LL27Gac&limit=20" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-26T00:30:02.301347Z", "recipient": "user@example.com", "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac", "meta": { "vendor_message_id": "7df213d7-fa5d-4ceb-88eb-5ce0198103a6" } },
{ "type": "queued", "at": "2026-07-26T00:30:02.284184Z", "recipient": "user@example.com", "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac", "meta": { "vendor": "resend" } }
],
"next_cursor": null,
"count": 2
}
}
That route requires message_id, so store the id you got back from the send against the user row — without it there’s no way to ask about a specific message later. A one-line aggregate is available too: GET /v1/email/get/{id} collapses the same message to a single state, which is usually all a support agent needs. An id the platform has never seen returns EMAIL_NOT_FOUND, which in practice means you polled with a staging id against production.
Five minutes of staleness is fine here; nobody is blocked on the answer.
Poll on a cron route, roughly every 5 minutes, for messages created in the last hour that haven’t reached a terminal state. That’s a handful of free reads per run.
The bill for a signup
The send is the only metered part: $0.000115 per recipient, verified 2026-07-26, with $2 of free credit on a new account covering roughly 17,391 messages. Template creation, template preview, suppression checks, message reads and the event feed are all free and rate-limited. Rates in this market keep sliding downward as vendor discounts land, so pull the current figure instead of trusting a page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"
Honest comparison
Resend is the incumbent answer for this exact stack, and it deserves to be: react-email plus their Next.js integration gives you JSX templates and typed responses, and if email is genuinely the only external service your app needs, that’s the shorter path. Postmark’s separate transactional stream and mature webhooks are the better pick if you need push notification of a bounce within seconds.
Two limitations here, stated plainly. There are no webhooks at all on this surface — every delivery signal is pulled. And custom sender domains need a paid plan: POST /v1/email/domain/verify answers 402 PRO_REQUIRED on a standard account, so until you upgrade, from falls back to a shared platform sender and mode reads default_vendor.
What you get for that is scope. The same key that sent the welcome also runs the cron entry that polls it, the queue that retries the failed one, and the error tracker that catches the exception in after() — one bill, one usage view, no second vendor onboarding.