Send email from Astro
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
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
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.
- 03
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.
- 04
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.
npm install @emails.sh/sdkimport { 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)
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
---
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.
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.