# Send email from Astro

Send transactional email from an Astro API route or action with emails.sh. Includes the output and adapter config that makes server routes run at all.

Astro is static by default, and a static site cannot hold an API key. This page shows the adapter and output setting that gives you a server, then the API route that sends. If your route returns 404 in production, the first section is why.

## Setup

1. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **Add an adapter** npx astro add node (or vercel, netlify, cloudflare). Without an adapter Astro has no server at runtime and API routes are not built.
1. **Opt the route into server rendering** export const prerender = false on the API route, or set output: "server" in astro.config.mjs to flip the default for the whole site.
1. **Read the key from the server** Astro exposes non PUBLIC_ variables through import.meta.env on the server only. Anything named PUBLIC_* is inlined into client JavaScript.

## Install

```bash
npm install @emails.sh/sdk
```

## astro.config.mjs

```ts
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
	// Static pages stay static; routes that opt out with prerender = false get a
	// server. Without an adapter there is no server for them to run on.
	output: 'static',
	adapter: node({ mode: 'standalone' })
});
```

## The API route

src/pages/api/send.ts (Astro 4 or 5)

```ts
import type { APIRoute } from 'astro';
import { Emailssh } from '@emails.sh/sdk';

// Without this, Astro prerenders the route to a static file and it will not run.
export const prerender = false;

const mail = new Emailssh({ apiKey: import.meta.env.EMAILSSH_API_KEY });

export const POST: APIRoute = async ({ request }) => {
	const body = (await request.json()) as { email?: string; name?: string };

	if (!body.email?.includes('@')) {
		return new Response(JSON.stringify({ error: 'A valid email is required' }), {
			status: 400,
			headers: { 'content-type': 'application/json' }
		});
	}

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

	return new Response(JSON.stringify({ id: sent.id, status: sent.status }), {
		status: 200,
		headers: { 'content-type': 'application/json' }
	});
};
```

## The form

src/pages/signup.astro

```ts
---
const title = 'Sign up';
---

<html lang="en">
	<head><title>{title}</title></head>
	<body>
		<form id="signup">
			<input name="name" placeholder="Your name" />
			<input name="email" type="email" placeholder="you@example.com" required />
			<button type="submit">Sign up</button>
		</form>
		<p id="result"></p>

		<script>
			const form = document.getElementById('signup');
			const result = document.getElementById('result');

			form?.addEventListener('submit', async (event) => {
				event.preventDefault();
				const data = new FormData(form as HTMLFormElement);
				const response = await fetch('/api/send', {
					method: 'POST',
					headers: { 'content-type': 'application/json' },
					body: JSON.stringify({ email: data.get('email'), name: data.get('name') })
				});
				if (result) result.textContent = response.ok ? 'Check your inbox.' : 'That did not send.';
			});
		</script>
	</body>
</html>
```

## Worth knowing

### No adapter means no API routes

A pure static build drops src/pages/api entirely. The route works with astro dev, then 404s on the deployed site, which is the single most common Astro email bug.

### PUBLIC_ is the leak here too

import.meta.env.PUBLIC_ANYTHING is replaced with a literal in client JavaScript. Keep the key as EMAILSSH_API_KEY with no prefix, and read it only inside src/pages/api or a component frontmatter that is not prerendered.

### Astro 5 renamed the output modes

In Astro 5, output: "hybrid" is gone: use "static" plus per-route prerender = false, or "server" to flip the default. On Astro 4 both "hybrid" and "server" exist and either works.

### Astro Actions are an alternative

If you are on Astro 4.15 or later, defineAction in src/actions/index.ts gives you a typed call from the client without writing the fetch. The send body is identical, only the wrapper changes.


## Questions

### Why does my Astro API route work locally but 404 in production?

The route was prerendered. Add an adapter and set export const prerender = false on the route, then redeploy.

### Can I send email from a static Astro site?

Not from the site itself, since there is no server to hold the key. Either add an adapter, or point the form at a small function elsewhere that holds the key.

### Where do I put EMAILSSH_API_KEY in Astro?

In .env with no PUBLIC_ prefix, read as import.meta.env.EMAILSSH_API_KEY from server code only.


Docs: https://emails.sh/docs.md