Skip to content

Email

Next.js boilerplate with Postmark

Transactional email with separate broadcast streams and 45 days of history.

Transactional email through Postmark, behind one provider-agnostic send function. React Email templates are rendered to HTML and text before sending. Transactional and broadcast mail use separate message streams. The suppression list is mirrored from Postmark's own. A basic-auth webhook handles bounces, spam complaints and subscription changes.

What Postmark adds to the agent layer: 1 rule · 2 skills · 6 solution docs · 1 MCP server

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Postmark?

Pick it if

Products where the transactional mail has to arrive now: sign-in links, password resets, receipts. Also teams that send some marketing and want it kept on separate infrastructure from the mail that must not fail.

Watch out for

  • Transactional first. Broadcast streams exist and handle unsubscribes, but there is no segmentation, no drip builder and no marketing CRM.
  • Postmark does not sign webhooks. The endpoint is protected by basic auth over HTTPS, which works but puts the credential in the webhook config.
Show 3 more
  • No React rendering on Postmark's side. Templates are rendered to HTML and text in the app before sending, so the preview is exactly what ships.
  • No idempotency key on the API. This battery sets a deterministic Message-ID and stores the key as metadata instead.
  • New accounts go through a short approval before they can mail outside their own domain. Plan a day for it before launch.

What it costs

Free developer plan: 100 emails a month, no expiry. Paid plans start at $15/month for 10,000 emails. Every plan keeps 45 days of message history by default. Dedicated IPs (from $50/month) and longer retention are add-ons.

Prices change. Check with Postmark before you commit.

registry/tested.yaml

Tested with Postmark

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

What it adds

What Postmark adds to the repo

Read straight from the postmark manifest, so it is exactly what lands in your repo.

Environment variables

  • POSTMARK_SERVER_TOKENRequired

    Server API token, from your server's API Tokens tab. Not the Account token. Server-side only. The literal value POSTMARK_API_TEST makes Postmark validate every send and deliver nothing, which is the right value for local development and CI.

    Placeholder
    POSTMARK_API_TEST
  • EMAIL_FROMRequired

    The From address, in "Name <address@domain>" form. Its domain (or the exact address) must be verified in Postmark under Sender Signatures, or every send fails with a 422. Use a subdomain such as mail.example.com.

    Placeholder
    my-app <hello@mail.example.com>
  • POSTMARK_MESSAGE_STREAMOptional

    Id of the transactional message stream. Defaults to "outbound", the stream every server starts with. Magic links, receipts and every default send go here.

    Placeholder
    outbound
  • POSTMARK_BROADCAST_STREAMOptional

    Id of the broadcast stream, used only when a send passes stream: "broadcast". Defaults to "broadcast". Postmark adds the unsubscribe link and List-Unsubscribe headers on this stream.

    Placeholder
    broadcast
  • POSTMARK_WEBHOOK_USERNAMEOptional

    Basic-auth username you set on the webhook in Postmark. Postmark does not sign webhooks, so this pair is the only proof a request came from Postmark. Without it the route at /api/webhooks/postmark answers 503.

    Placeholder
    postmark
  • POSTMARK_WEBHOOK_PASSWORDOptional

    Basic-auth password for the webhook. Long and random: openssl rand -hex 32. Under 16 characters, or still this placeholder, and the route treats it as unset. Rotate it in Postmark and here together.

    Placeholder
    replace_with_a_long_random_string
  • EMAIL_OUTBOX_DIROptional

    Development and tests only. When set, sendEmail writes each message as a JSON file in this folder instead of sending it, so sign-in and reset links work with no inbox and no provider account. The end-to-end tests read their links from it (.e2e/outbox). Ignored on a deployment: it works in development and in a production build served on localhost.

    Placeholder
  • REPLY_TOOptional

    Default Reply-To for every send. Falls back to EMAIL_FROM when unset. Point it at a mailbox a human reads.

    Placeholder
    support@example.com

Dependencies

  • @react-email/render^2.1.0
  • postmark^5.1.0
  • react-email^6.11.0
  • server-only^0.0.1

Scripts

  • bun run email:dev

    bunx email dev --dir src/lib/email/templates

  • bun run email:send-test

    bun --conditions=react-server scripts/email/send-test.ts

  • bun run email:suppressions

    bun --conditions=react-server scripts/email/suppressions.ts

MCP server

  • postmark

    Command
    npx -y @activecampaign/postmark-mcp@2.1.1
    Environment
    DEFAULT_MESSAGE_STREAM, DEFAULT_SENDER_EMAIL, POSTMARK_SERVER_TOKEN

Files it writes

29 files, at these exact paths.

  • scripts/3 files
    • email/3 files
      • render-samples.ts
      • send-test.ts
      • suppressions.ts
  • src/16 files
    • app/1 file
      • api/1 file
        • webhooks/1 file
          • postmark/1 file
            • route.ts
    • lib/15 files
      • email/15 files
        • templates/7 files
          • layout.tsx
          • magic-link.tsx
          • receipt.tsx
          • reset-password.tsx
          • theme.ts
          • verify-email.tsx
          • welcome.tsx
        • address.ts
        • index.ts
        • outbox.ts
        • postmark.ts
        • retry.ts
        • suppression.ts
        • tags.ts
        • webhook-auth.ts
  • tests/2 files
    • unit/2 files
      • email-outbox.test.ts
      • email.test.ts
  • variants/8 files
    • errors-none/1 file
      • src/1 file
        • lib/1 file
          • email/1 file
            • observability.ts
    • errors-sentry/1 file
      • src/1 file
        • lib/1 file
          • email/1 file
            • observability.ts
    • orm-drizzle/3 files
      • slots/1 file
        • db-schema.ts
      • src/2 files
        • db/1 file
          • email-schema.ts
        • lib/1 file
          • email/1 file
            • store.ts
    • orm-none/1 file
      • src/1 file
        • lib/1 file
          • email/1 file
            • store.ts
    • orm-prisma/2 files
      • slots/1 file
        • prisma-models.prisma
      • src/1 file
        • lib/1 file
          • email/1 file
            • store.ts

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot env-required
  • @slot legal-processors
  • @slot verify-checks

The differentiator

What Postmark teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (1)

Loaded when the agent opens a matching file.

One send path, the right message stream, and respect for inactive recipients

Loads onsrc/lib/email/**src/app/api/webhooks/postmark/**src/db/email-schema.tsscripts/email/**src/lib/auth/**src/lib/billing/**.claude/rules/email-sending-discipline.md
One send path

sendEmail() from @/lib/email is the only way this app sends mail.

  • Nothing else imports postmark, builds a ServerClient, or reads POSTMARK_SERVER_TOKEN.
  • The client lives in src/lib/email/postmark.ts and is built on first use. Never at module scope: next build imports every route with no secrets set.
  • The Resend and Mailgun batteries export the same sendEmail, SendEmailOptions, SendEmailResult, EmailAttachment and EmailSuppressedError. Keep it that way. Add options, never rename or narrow them.
  • Postmark-only options: stream, tag, tracking. Shared callers (auth, billing) must not set them.
import { sendEmail } from "@/lib/email";
import ReceiptEmail from "@/lib/email/templates/receipt";

await sendEmail({
  to: user.email,
  subject: `Receipt ${payment.reference}`,
  react: ReceiptEmail({ reference: payment.reference /* ... */ }),
  idempotencyKey: `receipt:${payment.id}`,
  tag: "receipt",
});
Message streams

Postmark splits mail into streams. They use separate IP pools and separate suppression lists.

StreamEnvDefault idFor
TransactionalPOSTMARK_MESSAGE_STREAMoutboundAnything the user just triggered: sign-in, reset, receipt, invite
BroadcastPOSTMARK_BROADCAST_STREAMbroadcastNewsletters, announcements, digests, promos
  • sendEmail defaults to transactional. Broadcast is stream: "broadcast", per send.
  • Never send marketing through the transactional stream. One promo blast that draws complaints hurts every magic link after it. Postmark's sending policy requires the split.
  • A "transactional" email with a promo paragraph is marketing. Keep receipts and welcome mails free of upsells.
  • On broadcast, Postmark adds the unsubscribe link and List-Unsubscribe headers. Do not add your own.
  • Never call messageStream() with a hardcoded id. Streams are configured by env.
Inactive recipients

Postmark deactivates an address after a hard bounce or a spam complaint. A later send to it fails with error 406.

  • sendEmail turns that 406 into EmailSuppressedError. Same error as a local hit.
  • Do not catch EmailSuppressedError and retry, resend through another path, or "fix" it by reactivating. The address is dead or the person said stop.
  • Do show it to support: "we can't email this address, ask the user for a new one."
  • The local list (src/lib/email/store.ts) mirrors the transactional stream. The webhook fills it. isSuppressed fails open on a database error on purpose. Postmark still enforces.
  • unsuppress() is for an explicit request from the mailbox owner. Postmark will not reactivate a spam complaint through the API. Never bulk-reactivate.
  • suppress(address, "manual") writes to Postmark too. The webhook passes { mirrored: true } so it never writes back.
The webhook

src/app/api/webhooks/postmark/route.ts.

  • Postmark does not sign webhooks. The check is basic auth: POSTMARK_WEBHOOK_USERNAME / POSTMARK_WEBHOOK_PASSWORD, compared in constant time in webhook-auth.ts.
  • Never weaken it: no "skip auth in dev", no query-string secret, no IP check alone.
  • Missing credentials means 503, not an open door.
  • Every write is an upsert. Postmark retries any non-2xx, so a redelivery must change nothing.
  • Hard bounce (Inactive: true) and spam complaint suppress from any stream. SubscriptionChange acts on the transactional stream only.
Idempotency

Postmark has no idempotency key. idempotencyKey becomes a deterministic Message-ID header plus idempotency_key metadata.

  • Scope it to the event: receipt:${paymentId}, welcome:${userId}. Never a timestamp or a random value.
  • No key on a magic link. A second request must send a second link.
Metadata, tags and tracking
  • tags becomes Postmark Metadata. At most 10 fields (9 with an idempotency key), names up to 20 characters, values up to 80. tags.ts enforces it.
  • tag is the single Postmark Tag for stats. A category like receipt. Never an id or an address.
  • tracking is off unless a person decided otherwise. Never on a magic link, reset or receipt. Tracked links get rewritten, and a rewritten sign-in link is a phishing signal.
Secrets and content
  • No API keys, env values, passwords or session tokens in any email body.
  • A magic link is the one credential a message may carry. Pass the URL as a prop. Never log it.
  • Templates live in src/lib/email/templates as React Email components. sendEmail renders HTML and text. Never build HTML with string concatenation.
  • Every template takes a locale. Pass the recipient's.
The Postmark MCP server

.mcp.json ships the official Postmark MCP server. Use it to diagnose: diagnoseDelivery, searchOutboundMessages, searchBounces, getMessageDetails.

  • Never use its send tools to mail a real person. Test sends go through bun run email:send-test.
  • Never create, edit or delete Postmark templates with it. Templates live in this repo.

Skills (2)

Invoked by name.

  • /add-email-template

    Add a React Email template on the shared layout, pick its message stream, wire it into a sendEmail call, and prove it lands in a real inbox.

    .claude/skills/add-email-template/SKILL.md

  • /test-email

    Prove Postmark sending works. Check the token and stream, send one of each template, read the activity feed, check suppressions, and test the basic-auth webhook with a fake bounce.

    .claude/skills/test-email/SKILL.md

Solution docs (6)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

Show all 6

How it fits

What Postmark needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

Nothing. Postmark stands on its own.

Pairs well with

  • An ORM battery. Suggested, never added for you.

Compared with the alternatives

Build a repo with Postmark

Free and MIT. The builder opens with Postmark picked. You download the zip right away, and we email you the link too.