Send email from Next.js

You need a signup or contact form in Next.js to actually deliver mail. This page gives you the whole route handler, the server action version, and the one env var it reads. Everything here runs on the server, which is the only place your API key is safe.

Setup

  1. 01

    Create a key

    Go to https://emails.sh/dashboard and create an API key. It starts with esh_ and is shown once.

  2. 02

    Put it in .env.local

    Name it EMAILSSH_API_KEY with no NEXT_PUBLIC_ prefix. Next.js only exposes NEXT_PUBLIC_ variables to the browser bundle, so the plain name stays server-side.

  3. 03

    Add the route handler

    app/api/send/route.ts below. Route handlers run on the server on every deployment target, so the key is never serialised into a client component.

  4. 04

    Send from a verified domain

    Until you verify your domain at https://emails.sh/dashboard/domains, set from to onboarding@emails.sh. Sending from an unverified domain returns 422 invalid_from_domain.

Install
npm install @emails.sh/sdk
.env.local
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"

The route handler

app/api/send/route.ts (Next.js 14 or 15, App Router)

The route handler
import { NextResponse } from 'next/server';
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

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

	if (typeof email !== 'string' || !email.includes('@')) {
		return NextResponse.json({ error: 'A valid email is required' }, { status: 400 });
	}

	const sent = await mail.send({
		from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
		to: [email],
		subject: 'Welcome to Acme',
		html: `<p>Hi ${name ?? 'there'}, your Acme account is ready.</p>`,
		text: `Hi ${name ?? 'there'}, your Acme account is ready.`
	});

	return NextResponse.json({ id: sent.id, status: sent.status });
}

The form that calls it

app/signup/page.tsx

The form that calls it
'use client';

import { useState } from 'react';

export default function SignupPage() {
	const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');

	async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
		event.preventDefault();
		setState('sending');

		const form = new FormData(event.currentTarget);
		const response = await fetch('/api/send', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ email: form.get('email'), name: form.get('name') })
		});

		setState(response.ok ? 'sent' : 'error');
	}

	return (
		<form onSubmit={onSubmit}>
			<input name="name" placeholder="Your name" />
			<input name="email" type="email" placeholder="you@example.com" required />
			<button type="submit" disabled={state === 'sending'}>
				{state === 'sending' ? 'Sending' : 'Sign up'}
			</button>
			{state === 'sent' ? <p>Check your inbox.</p> : null}
			{state === 'error' ? <p>That did not send. Try again.</p> : null}
		</form>
	);
}

The server action version

app/actions/send-welcome.ts, if you would rather skip the route

The server action version
'use server';

import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function sendWelcome(formData: FormData) {
	const email = String(formData.get('email') ?? '');
	if (!email.includes('@')) return { ok: false as const, error: 'A valid email is required' };

	const sent = await mail.send({
		from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
		to: [email],
		subject: 'Welcome to Acme',
		html: '<p>Your Acme account is ready.</p>',
		text: 'Your Acme account is ready.'
	});

	return { ok: true as const, id: sent.id };
}

Worth knowing

01

NEXT_PUBLIC_ would ship your key to the browser

Anything named NEXT_PUBLIC_* is inlined into the JavaScript bundle at build time and readable by anyone who opens devtools. Keep the variable named EMAILSSH_API_KEY, and never read it from a component without "use server" or from a client component at all.

02

Do not call the API from a client component

fetch("https://emails.sh/v1/emails") in a "use client" file means the Authorization header is in the browser. Route handlers and server actions exist precisely so the request originates from your server.

03

Vercel edge runtime is fine, nodemailer is not

If you set export const runtime = "edge" on the route, the SDK still works because it is fetch based. SMTP libraries like nodemailer do not, because the edge runtime has no TCP sockets. That is the usual reason a nodemailer plus Gmail setup works locally and fails once deployed.

04

Restart the dev server after editing .env.local

Next.js reads .env.local once at startup. If process.env.EMAILSSH_API_KEY is undefined and the SDK throws on construction, stop and restart next dev before you debug anything else.

Questions

How do I send an email in Next.js without a backend?

You already have one. A route handler in app/api/*/route.ts and a server action both run on your server, so they are the backend, and they are where the API key belongs.

Route handler or server action?

A server action if the send is triggered by a form in your own app, a route handler if anything external needs to POST to it (a webhook, a mobile client, a cron job).

Does this work in the pages router?

Yes. Put the same body in pages/api/send.ts using the (req, res) signature, and reply with res.status(200).json({ id: sent.id }).

Why did my send return 422 invalid_from_domain?

The domain in from is not verified on your workspace. Verify it at https://emails.sh/dashboard/domains, or send from onboarding@emails.sh while you are testing.

The rest of the API

POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.