Send email with Astro without exposing your Volanea API key to the browser by placing the API call in a server-side Astro endpoint. This guide configures Astro for on-demand rendering, adds a protected POST route, and sends a complete transactional email through Volanea’s REST API.
This implementation intentionally uses the platform Fetch API instead of an unverified framework wrapper. That keeps the integration small, makes every network boundary explicit, and works in a standard Astro project running with the Node adapter. The browser calls your Astro endpoint; the Astro endpoint calls Volanea; the secret key never crosses into client-side JavaScript.
What you will build
You will create a small Astro application flow with three pieces:
- An Astro Node adapter so a route can run at request time instead of only during the static build.
- A private
VOLANEA_API_KEYenvironment variable loaded only by server-side code. - A
POST /api/send-welcomeendpoint that usesfetch()to submit one transactional email to Volanea.
The example includes a simple page with an email-address form. When someone submits the form, client-side JavaScript sends that address to the Astro API route. The route validates the request, builds the message on the server, then sends it to Volanea at https://api.volanea.com/v1/send.
The message is deliberately not assembled from arbitrary browser-provided HTML, subject lines, or sender identities. A public form should provide only the minimum input required for the action. In this case, that is the recipient address. Your server owns the transactional message content and the authenticated email API request.
Prerequisites
Before adding the code, make sure you have the following:
- An Astro project using a currently supported Node.js release.
- A Volanea secret API key. Volanea documents secret keys in the
sk_…orsk_test_…format. - A sending domain and sender address that are ready to use in your Volanea account.
- A recipient inbox you control for testing.
A test key is the safest choice while you are validating the request path. Volanea documents sk_test_… keys for test mode, where requests can be rendered and logged without sending a live message. Switch to a live secret key only after the route works as expected.
This guide assumes a Node deployment target. Astro’s default output is static, which means a route would otherwise run while the site is built rather than when the browser submits the form. Transactional email must run on the server at request time, after your application has decided that an email should be sent.
Install the Astro server dependency
Run this from the root of your Astro project:
npx astro add node
That command installs and configures Astro’s official Node adapter. It is the only dependency this example needs: modern Node runtimes provide fetch() natively, so you do not need an extra HTTP client or a fictional Astro-specific email SDK.
The adapter command updates your Astro configuration automatically. Open astro.config.mjs afterward and confirm that it contains a Node adapter configuration similar to this:
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
});
If npx astro add node already added the adapter but did not set output: 'server', add that option yourself. Server output is the simplest setup for an application that expects API routes to execute dynamically. You can also use per-route on-demand rendering, but setting server output makes the intent clear for this starter integration.
Do not add the Volanea key to astro.config.mjs. Astro does not load .env files inside configuration files in the same way it does in application code, and configuration is not the right place to construct an authenticated API client anyway.
Add your Volanea API key safely
Create a .env file in the project root if you do not already have one:
VOLANEA_API_KEY="sk_test_replace_with_your_key"
VOLANEA_FROM_EMAIL="Example App <hello@your-verified-domain.com>"
Replace both values before testing:
VOLANEA_API_KEYshould be a Volanea secret key. Start with a test key when available.VOLANEA_FROM_EMAILmust be a sender identity associated with a domain you have configured for sending.
Do not prefix either variable with PUBLIC_. In Astro, variables with the PUBLIC_ prefix can be exposed to browser code. A sending key must remain server-only, so use a private name such as VOLANEA_API_KEY and access it only in server routes, server-rendered pages, or server-side modules.
Also add .env to .gitignore if your project does not already ignore environment files:
.env
.env.*
!.env.example
For collaborators and deployment environments, commit an .env.example file with names but no real credentials:
VOLANEA_API_KEY=""
VOLANEA_FROM_EMAIL="Example App <hello@your-verified-domain.com>"
Your hosting provider should receive the real values through its encrypted environment-variable settings. Never paste the key into a page component’s browser script, a public repository, a client-side form, or a build log.
Create the transactional email endpoint
Create this file:
src/pages/api/send-welcome.ts
Then paste in the complete endpoint below:
import type { APIRoute } from 'astro';
export const prerender = false;
const VOLANEA_SEND_URL = 'https://api.volanea.com/v1/send';
function isValidEmail(value: unknown): value is string {
if (typeof value !== 'string') return false;
const email = value.trim();
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export const POST: APIRoute = async ({ request }) => {
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return new Response(
JSON.stringify({ error: 'Expected an application/json request body.' }),
{
status: 415,
headers: { 'Content-Type': 'application/json' },
},
);
}
const apiKey = import.meta.env.VOLANEA_API_KEY;
const from = import.meta.env.VOLANEA_FROM_EMAIL;
if (!apiKey || !from) {
console.error('Missing VOLANEA_API_KEY or VOLANEA_FROM_EMAIL.');
return new Response(
JSON.stringify({ error: 'Email delivery is not configured.' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
);
}
let body: { email?: unknown };
try {
body = await request.json();
} catch {
return new Response(
JSON.stringify({ error: 'Request body must contain valid JSON.' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
);
}
if (!isValidEmail(body.email)) {
return new Response(
JSON.stringify({ error: 'Provide a valid recipient email address.' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
);
}
const recipient = body.email.trim().toLowerCase();
const volaneaResponse = await fetch(VOLANEA_SEND_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
from,
to: [recipient],
subject: 'Welcome to Example App',
html: `
<h1>Welcome to Example App</h1>
<p>Thanks for signing up.</p>
<p>You can now return to the app and finish setting up your account.</p>
`,
text: [
'Welcome to Example App',
'',
'Thanks for signing up.',
'You can now return to the app and finish setting up your account.',
].join('\n'),
}),
});
const responseText = await volaneaResponse.text();
let responseBody: unknown = null;
if (responseText) {
try {
responseBody = JSON.parse(responseText);
} catch {
responseBody = { raw: responseText };
}
}
if (!volaneaResponse.ok) {
console.error('Volanea send request failed:', {
status: volaneaResponse.status,
responseBody,
});
return new Response(
JSON.stringify({
error: 'The email provider rejected the send request.',
}),
{
status: 502,
headers: { 'Content-Type': 'application/json' },
},
);
}
return new Response(
JSON.stringify({
ok: true,
message: 'Transactional email accepted for processing.',
providerResponse: responseBody,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
};
This route has export const prerender = false, which explicitly tells Astro that it must not be generated as a static file at build time. The POST export becomes an API route at /api/send-welcome.
The request to Volanea includes the essential HTTP pieces for a JSON REST call:
POSTsends a new email request.Authorization: Bearer …carries the secret key from the server environment.Content-Type: application/jsonidentifies the serialized request payload.Accept: application/jsonrequests a JSON response.JSON.stringify()converts the JavaScript object into valid JSON bytes for the HTTP body.
The body contains a sender, recipient list, subject, HTML version, and text version. Providing both html and text gives receiving clients a plain-text alternative and makes the message more resilient in mail environments that limit HTML rendering.
Add a page that calls the endpoint
Create or replace src/pages/index.astro with this example page:
---
const pageTitle = 'Send a welcome email';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{pageTitle}</title>
</head>
<body>
<main>
<h1>{pageTitle}</h1>
<p>Enter an address you control to test the Astro API route.</p>
<form id="welcome-form">
<label>
Email address
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
/>
</label>
<button type="submit">Send test email</button>
</form>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script>
const form = document.querySelector('#welcome-form');
const emailInput = document.querySelector('#email');
const status = document.querySelector('#status');
if (!(form instanceof HTMLFormElement)) {
throw new Error('Welcome form was not found.');
}
if (!(emailInput instanceof HTMLInputElement)) {
throw new Error('Email input was not found.');
}
if (!(status instanceof HTMLElement)) {
throw new Error('Status element was not found.');
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
if (!(button instanceof HTMLButtonElement)) return;
button.disabled = true;
status.textContent = 'Sending…';
try {
const response = await fetch('/api/send-welcome', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: emailInput.value,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error ?? 'Unable to send email.');
}
status.textContent = 'Email request accepted. Check the recipient inbox.';
form.reset();
} catch (error) {
status.textContent = error instanceof Error
? error.message
: 'Unable to send email.';
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>
The browser-side script does not receive the Volanea API key and does not call Volanea directly. It sends a narrow JSON payload to your own origin at /api/send-welcome. The private route checks the content type, parses JSON, validates the recipient address, and then creates the provider request.
That separation is not merely a matter of code organization. A secret email API key in the browser can be copied by any visitor and used outside your application. A server endpoint lets you enforce authorization, validate input, rate-limit requests, apply abuse controls, choose the sender, and decide which transactional message is allowed to leave your system.
Run and test the integration
Start the development server:
npm run dev
Open the local URL Astro prints in the terminal, normally http://localhost:4321. Submit a recipient address that you can inspect. With a test key, check the test-mode activity or logs in Volanea rather than expecting mail to arrive. With a live key and a verified sender identity, check the recipient inbox and the provider’s send activity.
You can also test the Astro endpoint without the form. Keep the development server running and use this command in a second terminal:
curl -i http://localhost:4321/api/send-welcome \
-X POST \
-H "Content-Type: application/json" \
--data '{"email":"you@example.com"}'
A successful route response is HTTP 200 and includes an ok: true value. That means your Astro endpoint received the form data and Volanea accepted the API request. It does not by itself prove the recipient has opened or received a message; acceptance, dispatch, delivery, bounce, and engagement are distinct events.
For production testing, send only to addresses you own until your sender identity, content, and application logic are confirmed. Make the send action idempotent in the business workflow where appropriate. For example, a password-reset request may safely generate a new token and send a new email, while a paid-order receipt should generally be protected so a browser retry does not create duplicates.
How the request flow works
When you send email with Astro in this setup, the request has two separate hops:
- The browser submits JSON to your Astro API route.
- The Astro server submits authenticated JSON to Volanea’s send endpoint.
This is an important distinction. The first request is part of your application and may be unauthenticated, authenticated with a user session, or protected by a form-specific anti-abuse mechanism. The second request is a privileged infrastructure request authenticated with a Volanea secret key.
Browser-to-Astro request
The page sends this small payload:
{
"email": "you@example.com"
}
The Content-Type header is application/json, which tells the route that request.json() is appropriate. The route rejects other content types with HTTP 415 Unsupported Media Type rather than attempting to guess how to parse the body.
Astro-to-Volanea request
The server constructs the full email message. It decides the sender, subject, HTML, and plain-text content. This means a malicious user cannot turn a welcome-email form into a general-purpose relay by supplying a different from address, custom HTML, or a list of thousands of recipients.
The example permits one recipient only. Volanea’s single-send endpoint supports a message addressed to one recipient or a limited group of recipients, but a public signup form should not expose a multi-recipient capability. Build recipient lists from trusted data on the server for product notifications, invoices, invitations, and similar application events.
Provider response handling
The endpoint reads the provider response with await volaneaResponse.text() and then tries to parse it as JSON. This pattern is useful because an error response is not always guaranteed to be parseable JSON. Reading the body once avoids the common mistake of calling both response.json() and response.text() on the same response stream.
For a failed provider request, the route logs the provider status and body on the server, then returns a generic 502 response to the browser. Avoid returning raw infrastructure errors to end users. They may include details that are useful for debugging but inappropriate to disclose publicly.
Adapt the sample for real transactional events
The sample is a testable starting point, not a recommendation to send email whenever an anonymous public form is submitted. In a production application, invoke the Volanea request only after the relevant domain event has occurred.
Typical transactional triggers include:
- A user has created an account and needs an onboarding message.
- A user requested a password reset and a fresh, short-lived token was created.
- A payment provider has confirmed an order or subscription event through a verified webhook.
- A workspace owner invited a known recipient to join an organization.
- A background job detected a failure or threshold that warrants an operational alert.
Keep the send operation close to the event that authorizes it. For example, after creating a user record in a server-side signup handler, call a dedicated sendWelcomeEmail() helper. For order receipts, send after your payment verification code confirms the provider event, not merely when the client says payment succeeded.
A useful next refactor is moving the Volanea call out of the route and into a server-only module. That makes the message reusable from multiple server routes, Astro Actions, scheduled workers, or webhook handlers.
// src/lib/server/send-welcome-email.ts
const VOLANEA_SEND_URL = 'https://api.volanea.com/v1/send';
export async function sendWelcomeEmail(recipient: string) {
const apiKey = import.meta.env.VOLANEA_API_KEY;
const from = import.meta.env.VOLANEA_FROM_EMAIL;
if (!apiKey || !from) {
throw new Error('Missing Volanea email configuration.');
}
const response = await fetch(VOLANEA_SEND_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
from,
to: [recipient],
subject: 'Welcome to Example App',
html: '<h1>Welcome to Example App</h1><p>Thanks for signing up.</p>',
text: 'Welcome to Example App\n\nThanks for signing up.',
}),
});
if (!response.ok) {
throw new Error(`Volanea returned HTTP ${response.status}.`);
}
return response.json();
}
The API route can then import and call that helper after it validates the incoming request. Keep this module under a clearly server-only directory or naming convention, and never import it into a client component or browser script.
Common errors
Authentication fails with 401 or 403
An authentication error usually means the key is missing, malformed, revoked, from the wrong environment, or not being sent in the expected authorization header. Confirm that .env contains VOLANEA_API_KEY, restart npm run dev after changing it, and verify that the route uses Authorization: Bearer ${apiKey}.
Do not log the key to diagnose this problem. Log only whether the value exists, such as Boolean(import.meta.env.VOLANEA_API_KEY). If you are using a test key, confirm that you are checking the corresponding test-mode activity rather than a live-sending view.
The API route returns 415 Unsupported Media Type
The endpoint intentionally requires Content-Type: application/json. If you use fetch() without the header, send a regular HTML form submission, or use FormData, the route rejects the request before it attempts request.json().
For the supplied client code, keep both of these lines together:
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.value }),
If you prefer native form submission or multipart data, change the route deliberately to use await request.formData() and validate the resulting field. Do not leave a JSON-only content-type check in place while sending form data.
Unexpected token or invalid JSON errors
This happens when the request body is not valid JSON, often because plain text was sent with an application/json header or because an object was passed to body without JSON.stringify(). Fetch does not automatically serialize JavaScript objects into JSON.
The browser request must use JSON.stringify(), and the Volanea request must do the same. JSON parsing should also be wrapped in a try/catch at the server boundary, as in the complete sample, so invalid client input results in a useful HTTP 400 response rather than an uncaught exception.
Nothing happens because await is missing
fetch() is asynchronous. If you omit await, your code receives a Promise, not an HTTP Response. Trying to read response.ok, response.status, or response.json() from that promise causes errors or incorrect control flow.
Use await for both network calls in this guide:
const response = await fetch('/api/send-welcome', options);
const result = await response.json();
In the server endpoint, also await fetch() and await the body-reading method exactly once. This ensures your route does not return success before it knows whether Volanea accepted the request.
The route works locally but sends no email
First determine whether the API request was accepted. Inspect the Astro terminal for server-side errors and inspect Volanea’s sending activity for the request. A successful HTTP call is different from successful delivery to an inbox.
Then check sender readiness and recipient conditions: the configured sender must be usable, the message may be suppressed due to a prior bounce, complaint, unsubscribe, or manual block, and the recipient may filter the mail. Use an inbox you control while testing and check spam or junk folders where appropriate.
You see Cannot use import.meta.env or the key is undefined
Private Astro environment variables are available in server-side application code, but they are not intended for arbitrary client-side scripts. Confirm that the code reading VOLANEA_API_KEY is in src/pages/api/send-welcome.ts or another server-only module, not inside the page’s browser <script>.
Restart the development server after adding or editing .env. Environment files are loaded when Astro starts, so a running dev process may not observe a newly added key until it restarts.
Your endpoint runs during build instead of on form submission
This indicates a static-output configuration or a route that is being prerendered. Ensure the Node adapter is configured, use output: 'server' in astro.config.mjs, and keep export const prerender = false in the API route.
A static site can display the form, but it cannot securely perform a per-request send without a server or serverless runtime. The route must execute where the private environment variable exists.
Duplicate emails appear after retries
Browsers, users, reverse proxies, and application code can retry requests. Prevent duplicates at the business layer before sending an irreversible transactional message. Store a record that a specific event was sent, use a durable job queue for critical messages, and use Volanea’s idempotency support where your workflow needs a safe retry key.
For example, create an order receipt key from the stable order ID and message type. Reuse that key only for retries of the same intended message, not for unrelated receipts. This avoids both accidental duplicates and over-broad deduplication.
Delivery, security, and production considerations
Email sending is a side effect, so treat it differently from rendering a page. A robust production implementation should have clear responsibility for authentication, authorization, validation, logging, retry behavior, and observability.
Require authorization for protected messages
The demo form is suitable for controlled testing, not for a sensitive action. A route that sends invoices, exports, account notices, or workspace invitations should verify the requester’s session and permissions before it sends.
Do not trust a browser-supplied user ID, organization ID, order ID, sender, or recipient list. Derive these from the authenticated session and your database. The server should be able to explain why a particular user was allowed to initiate a particular message.
Rate-limit public email actions
Signup confirmations, magic links, password resets, and contact forms can attract abuse. Apply rate limits by IP address, account, recipient address, and action type as appropriate. Consider CAPTCHA or other challenge mechanisms for anonymous public forms, but do not rely solely on them.
Rate limits should protect both your application and your sender reputation. A valid API key is not a substitute for application-level abuse controls because the key authorizes your server, not every visitor to your site.
Keep message content deterministic
Transactional messages should be tied to a known event and should be reproducible from trusted data. Store enough information to understand what was sent, when, why, and to whom, without storing secrets unnecessarily.
For account actions, avoid putting sensitive data directly in email. Send an expiring, single-use URL or direct the recipient to sign in. For receipts and notices, keep the email concise and make the canonical details available within the authenticated application.
Separate acceptance from delivery outcomes
An API response can indicate that the provider accepted a message for processing. It cannot guarantee that a mailbox provider accepted the message, that it reached the inbox rather than spam, or that a user read it.
Use delivery event handling for lifecycle-aware application behavior. For example, a hard bounce may mean you should stop retrying a notification to that address, while a delivery event can update internal support tooling. Do not use open tracking as proof that a user saw a critical security notice.
Next steps
Once the direct HTML message is working, move from a single test route to reusable sending infrastructure.
First, use templates for transactional messages that need consistent content across your product. A template lets your application identify reusable email content instead of embedding every version of the markup in route handlers. Keep template variables sourced from trusted server data, validate required values before a send, and test changes with non-production recipients before rollout.
Second, add webhooks so your application can receive lifecycle events after a send request. Webhooks are useful for recording delivery outcomes, bounces, complaints, and other provider events in your own system. Verify every webhook signature before trusting its payload, acknowledge valid requests quickly, and process heavier work asynchronously so a retry does not cause duplicate side effects.
For endpoint details, payload fields, templates, and event handling guidance, consult the Volanea API reference and setup guides. As your send volume grows, also decide how your application will queue retries, record idempotency keys, and surface failed sends to operators.
FAQ
Can I send email with Astro from a static site?
Not directly at request time. A purely static Astro build has no server code running when a visitor submits a form. Add an SSR adapter or deploy an API route to a server or serverless runtime so the email request can run privately after submission.
Should I call Volanea directly from an Astro page script?
No. Calling the provider directly from browser JavaScript would expose your secret API key to every site visitor. Call your own server-side Astro endpoint instead, and let that endpoint make the authenticated Volanea request.
Do I need a Volanea SDK for Astro?
No. This guide uses the native Fetch API, which is available in modern Node runtimes and is sufficient for a JSON REST request. The required Astro dependency is the Node adapter that enables server-side routes.
Why send both HTML and text versions of the email?
HTML provides formatted content for capable mail clients, while plain text provides a readable fallback for clients or user settings that limit HTML. Supplying both also makes the message content easier to inspect and test.
How do I prevent duplicate transactional emails?
Tie the send to a durable application event, store an idempotency record or key for that event, and make retries reuse that same key only when they represent the same intended message. Do not treat a browser request alone as proof that a new email should be created.