Step-by-step guideTypeScript · Server-side

Configure email providers

Connect Resend or SendGrid with a typed adapter, configure a sender, and route messages between providers.

BEFORE YOU START

Start with a synced Papier client in a server-side ESM TypeScript project on Node.js 22.23.4 or later. You’ll need a Resend or SendGrid account, a sending API key, and a verified sender. These examples implement the public EmailAdapter contract with fetch, so no provider SDK package is required.

01

Prepare your provider credentials#

Verify your sender address or domain with the provider you’ll use. Add your Papier API key and that provider’s API key to the server environment. Only add both provider keys if you plan to use the routing example.

.env
PAPIER_API_KEY=your_papier_api_key
RESEND_API_KEY=your_resend_api_key
# Optional: needed for the SendGrid adapter and fallback example.
SENDGRID_API_KEY=your_sendgrid_api_key

Keep these values in your server’s secret configuration. Don’t put them in browser bundles or commit your local .env file.

02

Create typed provider adapters#

An adapter has a unique name and an asynchronous send(input) method. Papier supplies the recipient, configured sender, subject, rendered HTML, and template data. Return a provider ID when available, and throw if the provider rejects the request.

Save the following as providers.ts. Both adapters require the object sender configured in the next step, plus an explicit subject. They send HTML; extend the mapping if your application needs other content formats.

providers.ts
import type { EmailAdapter } from '@usepapier/client/base';

type EmailInput = Parameters<EmailAdapter['send']>[0];

function message(input: EmailInput) {
  // These examples use the object sender configured in emailConfig.from.
  if (!input.from || typeof input.from === 'string') {
    throw new Error('Configure a sender object with an email and optional name.');
  }
  if (!input.subject || !input.html) {
    throw new Error('A subject and rendered HTML are required.');
  }
  return {
    from: input.from,
    to: Array.isArray(input.to) ? input.to : [input.to],
    subject: input.subject,
    html: input.html,
  };
}

export function createResendAdapter(apiKey: string): EmailAdapter {
  return {
    name: 'resend',
    async send(input) {
      const mail = message(input);
      const response = await fetch('https://api.resend.com/emails', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          from: mail.from.name
            ? `${mail.from.name} <${mail.from.email}>`
            : mail.from.email,
          to: mail.to,
          subject: mail.subject,
          html: mail.html,
        }),
      });
      if (!response.ok) throw new Error(`Resend rejected email (${response.status}).`);
      const body: unknown = await response.json();
      if (!body || typeof body !== 'object' || !('id' in body) || typeof body.id !== 'string') {
        throw new Error('Resend did not return an email ID.');
      }
      return { id: body.id, provider: 'resend' };
    },
  };
}

export function createSendGridAdapter(apiKey: string): EmailAdapter {
  return {
    name: 'sendgrid',
    async send(input) {
      const mail = message(input);
      const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          personalizations: mail.to.map(email => ({ to: [{ email }] })),
          from: mail.from,
          subject: mail.subject,
          content: [{ type: 'text/html', value: mail.html }],
        }),
      });
      if (!response.ok) throw new Error(`SendGrid rejected email (${response.status}).`);
      return { id: response.headers.get('x-message-id') ?? undefined, provider: 'sendgrid' };
    },
  };
}

Provider request formats: Resend Send Email ↗ · SendGrid Mail Send ↗. The SendGrid example uses its global API endpoint.

03

Register a sender and adapter#

Create client.ts beside providers.ts. Configure a verified sender in emailConfig.from, then register the Resend adapter.

client.ts
import { createPapier } from '@usepapier/client';
import { createResendAdapter } from './providers.js';

const papierApiKey = process.env.PAPIER_API_KEY;
const resendApiKey = process.env.RESEND_API_KEY;
if (!papierApiKey || !resendApiKey) {
  throw new Error('Set PAPIER_API_KEY and RESEND_API_KEY on the server.');
}

export const papier = createPapier({
  project: 'acme',
  apiKey: papierApiKey,
  environment: 'production',
  defaultLocale: 'en',
  emailConfig: {
    // Replace this with a sender verified in your provider account.
    from: { name: 'Acme', email: 'hello@your-domain.com' },
    adapters: [createResendAdapter(resendApiKey)],
  },
});

To use SendGrid instead, import createSendGridAdapter, read SENDGRID_API_KEY, and register createSendGridAdapter(sendGridApiKey). The rest of your template send code stays the same.

Continue with send through a provider to call papier.emails.welcomeEmail.send(). The template’s content comes from Papier; the subject in these examples is supplied by your application.

04

Configure routing and fallback#

Give each adapter a distinct name. A routing callback can select adapters by name, using the template slug and message details. Returned arrays define fallback order: the SDK stops at the first successful adapter.

For this optional example, replace client.ts with the code below. It tries Resend and then SendGrid for welcome-email. Other email templates use SendGrid. Set PAPIER_TEST_MODE=true to explicitly select the virtual inbox during development.

client.ts · optional routing configuration
import { createPapier } from '@usepapier/client';
import { virtualInboxAdapter } from '@usepapier/client/base';
import { createResendAdapter, createSendGridAdapter } from './providers.js';

function requiredEnv(name: string) {
  const value = process.env[name];
  if (!value) throw new Error(`Set ${name} on the server.`);
  return value;
}

// An explicit opt-in keeps local tests in the virtual inbox.
const testMode = process.env.PAPIER_TEST_MODE === 'true';

export const papier = createPapier({
  project: 'acme',
  apiKey: requiredEnv('PAPIER_API_KEY'),
  environment: testMode ? 'development' : 'production',
  emailConfig: {
    from: { name: 'Acme', email: 'hello@your-domain.com' },
    adapters: testMode
      ? [virtualInboxAdapter]
      : [
          createResendAdapter(requiredEnv('RESEND_API_KEY')),
          createSendGridAdapter(requiredEnv('SENDGRID_API_KEY')),
        ],
    routing: ({ templateSlug }) => {
      if (testMode) return ['virtual-inbox'];
      return templateSlug === 'welcome-email'
        ? ['resend', 'sendgrid']
        : ['sendgrid'];
    },
  },
});

The environment option labels the SDK context; it doesn’t turn a real provider into a test provider. Select virtualInboxAdapter to avoid delivery during local tests.

Without routing, adapters are attempted in registration order. If routing produces no matching adapters, the SDK falls back to that registered list. A transport timeout can leave provider acceptance uncertain, so account for duplicate sends when designing retries and fallback.

05

Verify the result#

Send to your own address first. Inspect the returned adapter name and optional id, then check your provider’s activity and delivery events. The SDK attempts to write send results to Papier’s logs as well.

No email adapters configured
Add an adapter to emailConfig.adapters.
Provider rejects the request
Check the key’s sending permission, verified sender, recipient restrictions, and provider logs. The example adapters include the HTTP status in the error.
All adapters failed
Inspect the combined SDK error and provider activity before retrying.

Keep building.