Frameworks and agents · Updated 2026-09-22
Send email from Next.js
A route handler and a Server Action that send through AgentiSend, with the key on the server and an idempotency key on every send.
To send email from a Next.js App Router app, call AgentiSend from a route handler or a Server Action, where the API key stays on the server and never reaches the browser. The two files below are executed against a real API on every build of this site's repository, so a sample that stops working fails the build before it reaches this page.
Install
pnpm add agentisendEnvironment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. Create one in the console or with POST /api-keys. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
A route handler
Save this as app/api/send/route.ts. It returns the message id, which is what you store against the user row: every later question, from "did it arrive?" to "why was it held?", is answered by GET /emails/{id} and GET /emails/{id}/explain with that id.
/**
* app/api/send/route.ts — a Next.js App Router route handler.
*
* Runs on the server only, so the API key never reaches the browser. The
* handler returns the message id, which is what you store against the user
* row: every later question ("did it arrive?", "why was it held?") is answered
* by GET /emails/:id and GET /emails/:id/explain with that id.
*/
import { AgentiSend, AgentiSendError } from 'agentisend';
const agentisend = new AgentiSend();
/** The verified sender. Failing here beats failing at the API with a 422. */
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
export const runtime = 'nodejs';
export async function POST(request: Request): Promise<Response> {
const { email, name } = (await request.json()) as { email?: string; name?: string };
if (!email) {
return Response.json({ error: 'email is required' }, { status: 400 });
}
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: 'Welcome',
html: `<p>Hello ${name ?? 'there'} — your account is ready.</p>`,
},
// Derived from the user, not from the moment: a retried request after a
// timeout replays the first send instead of mailing them twice.
{ idempotencyKey: `welcome/${email}` },
);
return Response.json({ id });
} catch (err) {
if (err instanceof AgentiSendError) {
// Every 4xx carries `fix` — what to do, and which endpoint does it.
return Response.json({ code: err.code, fix: err.fix }, { status: err.status });
}
throw err;
}
}The same send as a Server Action
Save this as app/actions.ts, with 'use server' as its first line, for a form that posts straight to the server with no route handler in between.
/**
* app/actions.ts — the same send as a Server Action, for a form that posts
* straight to the server with no route handler in between.
*
* `'use server'` belongs at the top of the real file; it is a comment here so
* that this directory typechecks without the Next.js compiler.
*/
// 'use server';
import { AgentiSend, AgentiSendError } from 'agentisend';
const agentisend = new AgentiSend();
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
export interface InviteResult {
id?: string;
error?: { code: string; fix: string };
}
export async function inviteTeammate(formData: FormData): Promise<InviteResult> {
const email = String(formData.get('email') ?? '');
if (!email) return { error: { code: 'validation_error', fix: 'Fill in the email field.' } };
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: 'You have been invited',
text: 'Open the console to accept the invitation.',
},
{ idempotencyKey: `invite/${email}` },
);
return { id };
} catch (err) {
if (err instanceof AgentiSendError) return { error: { code: err.code, fix: err.fix } };
throw err;
}
}Try it
curl -X POST localhost:3000/api/send \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","name":"Ada"}'The response is {"id":"…"}. Open the console, or call GET /emails/{id}, to watch it move from queued to delivered.
Why the idempotency key is derived from the user
A retried request after a timeout carries the same key, so the API replays the first send instead of mailing the same person twice. Derive the key from the thing being done, never from the moment.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The route handler above forwards code and fix, so a client can act on a refusal without a human reading prose. The error catalogue lists every code.
Coming from Resend
The request shape is the same, and the SDK's constructor reads the key from the environment the same way. Migrating from Resend lists the two behaviours that differ on purpose.