Integration

Send email from Astro with SNDR.

Send transactional email from Astro — API routes, Actions, server endpoints, and webhook receivers. Works with the Node, Vercel, Cloudflare, and Netlify adapters.

Astro is increasingly the framework of choice for content-heavy sites that need a small amount of dynamic behavior. Use a Node-compatible server adapter for the SDK. Cloudflare requires nodejs_compat; use the HTTP API on Edge targets without Node crypto. The recipes below show the four patterns: send from an API route, send from a typed Astro Action, verify an incoming webhook, and a small OTP flow.

Setup

Install + configure

Installbash
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr
Environmentenv
# .env (Astro reads these via import.meta.env when prefixed with PUBLIC_,
# and via process.env on server-only code — never expose SNDR_API_KEY to
# the client.)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...   # only if you receive webhooks

Recipes

The patterns you actually need.

  1. Enable SSR + add an adapter

    Astro is static by default — to run server code you need output: "server" (full SSR) or "hybrid" (per-page opt-in). Add the right adapter for your deploy target.

    // astro.config.mjs
    import { defineConfig } from 'astro/config';
    import vercel from '@astrojs/vercel/serverless';
    // or: import node from '@astrojs/node';
    // or: import cloudflare from '@astrojs/cloudflare';
    
    export default defineConfig({
      output: 'hybrid',
      adapter: vercel(),
    });
  2. Send from an API route

    Export a POST function from pages/api/<route>.ts. The endpoint context gives you the standard Web Request and a typed environment helper.

    // src/pages/api/welcome.ts
    import type { APIRoute } from 'astro';
    import { Sndr, SndrError } from '@rkiza/sndr';
    
    const sndr = new Sndr(import.meta.env.SNDR_API_KEY);
    
    export const POST: APIRoute = async ({ request }) => {
      const { email, name } = (await request.json()) as { email: string; name: string };
      try {
        const { id } = await sndr.emails.send({
          from: 'hello@yourdomain.com',
          to: email,
          subject: `Welcome, ${name}`,
          html: `<p>Thanks for joining us, ${name}.</p>`,
        });
        return new Response(JSON.stringify({ id }), {
          status: 200,
          headers: { 'content-type': 'application/json' },
        });
      } catch (err) {
        if (err instanceof SndrError) {
          return new Response(
            JSON.stringify({ error: { code: err.code, message: err.message } }),
            { status: (err.status >= 400 && err.status <= 599 ? err.status : 502), headers: { 'content-type': 'application/json' } },
          );
        }
        throw err;
      }
    };
  3. Send from a typed Astro Action

    Astro Actions (added in 4.15+) are typed server functions you can call from any component or page. Define the input schema with Zod once and Astro generates the client wrapper.

    // src/actions/index.ts
    import { defineAction } from 'astro:actions';
    import { z } from 'astro:schema';
    import { Sndr } from '@rkiza/sndr';
    
    const sndr = new Sndr(import.meta.env.SNDR_API_KEY);
    
    export const server = {
      sendContact: defineAction({
        accept: 'form',
        input: z.object({
          name: z.string().min(2).max(120),
          email: z.string().email(),
          message: z.string().min(10).max(8000),
        }),
        handler: async ({ name, email, message }) => {
          await sndr.emails.send({
            from: 'hello@yourdomain.com',
            to: 'team@yourdomain.com',
            reply_to: email,
            subject: `New contact from ${name}`,
            text: message,
          });
          return { ok: true };
        },
      }),
    };
  4. Verify an incoming webhook

    API routes give you the standard Request, so reading the raw bytes is request.arrayBuffer(). Verify the HMAC before parsing the JSON.

    // src/pages/api/webhooks/sndr.ts
    import type { APIRoute } from 'astro';
    import { verifyWebhookSignature } from '@rkiza/sndr';
    import type { WebhookEventPayload } from '@rkiza/sndr';
    
    export const POST: APIRoute = async ({ request }) => {
      const raw = Buffer.from(await request.arrayBuffer());
      const ok = verifyWebhookSignature(
        raw,
        request.headers.get('x-sndr-signature') ?? '',
        import.meta.env.SNDR_WEBHOOK_SECRET,
      );
      if (!ok) return new Response('bad signature', { status: 400 });
    
      const event = JSON.parse(raw.toString('utf8')) as WebhookEventPayload;
      switch (event.type) {
        case 'email.delivered':
          // event.data.email_id, event.data.to: string[]
          break;
        case 'email.bounced':
          // event.data.email_id, event.data.error
          break;
        case 'email.unsubscribed':
          // event.data.addresses: string[]
          break;
      }
    
      return new Response('ok');
    };
  5. OTP / 6-digit-code flow

    Generate a 6-digit code, persist it with a TTL, send it via SNDR, and validate on the next request. The SDK's auto idempotency key prevents double-send on retries.

    // src/pages/api/otp.ts
    import type { APIRoute } from 'astro';
    import { Sndr } from '@rkiza/sndr';
    
    const sndr = new Sndr(import.meta.env.SNDR_API_KEY);
    
    export const POST: APIRoute = async ({ request }) => {
      const { email } = (await request.json()) as { email: string };
      const code = String(Math.floor(100000 + Math.random() * 900000));
      // ...persist (email, code, expiresAt) with 10-min TTL...
    
      await sndr.emails.send({
        from: 'auth@yourdomain.com',
        to: email,
        subject: `Your sign-in code: ${code}`,
        text: `Your code is ${code}. It expires in 10 minutes.`,
      });
    
      return new Response('check your email');
    };

Astro FAQ

Why do I need output: "server" or "hybrid"?
Astro is static by default. API routes that need to run server-side require either full SSR (output: "server") or per-page opt-in via "hybrid" mode plus export const prerender = false on the endpoint.
Will the SDK work on Cloudflare Pages / Workers?
Enable nodejs_compat for the entire SDK on Workers. On targets without Node compatibility, use the HTTP API instead.
How do I keep the API key out of the client bundle?
Only import Sndr in files inside src/pages/api/, src/actions/, src/middleware.ts, or in .astro files inside <script is:server> blocks. import.meta.env reads only the SNDR_API_KEY (no PUBLIC_ prefix) so Astro never exposes it to the browser bundle.
How do I handle webhook idempotency from my side?
Persist event.id in your database with a unique constraint and short TTL. If you see it again (network retried, etc.), no-op the second receipt.

Send your first Astro email.

3,000 emails a month on the Free plan. No credit card needed.