ShipKit docs
The stack

Email

React Email templates, three locales, rendered on the server and sent through Resend.

Five transactional templates ship, written as React components and rendered to HTML on the server:

TemplateSent when
verify-emailA new account needs its address confirmed
reset-passwordA password reset is requested
welcomeSign-up completes, queued rather than sent inline
magic-linkPasswordless sign-in is used
otp-codeA one-time code is requested

Set RESEND_API_KEY and EMAIL_FROM to turn sending on. Without them nothing is sent and nothing throws: sign-up still works, the verification mail simply never arrives. Magic-link sign-in only appears as an option once email is configured, because a login method that cannot deliver its link is worse than a missing button.

Locale is a parameter, not context

import { renderWelcomeEmail } from "@workspace/email"

const html = await renderWelcomeEmail({
  username: user.name,
  loginUrl: `${appUrl}/login`,
  locale: user.locale,        // "en" | "ru" | "uz"
})

Every render function takes an optional locale. This is deliberately unlike the web app, which resolves the locale from the request through next-intl.

Transactional email renders in the API or in the worker, where there is no request and therefore no locale context. A worker draining a queue an hour later has no idea what language the person who triggered the job reads, so the locale has to travel with the job payload. Pass it explicitly, or every email goes out in English.

An unknown or missing locale falls back to English rather than throwing, so a new locale in the database cannot break delivery.

Translations are data

Catalogs live in packages/email/src/messages/{en,ru,uz}.json, not in the .tsx templates. The repository enforces this: scripts/check-no-cyrillic.sh fails the build on non-English text inside .ts and .tsx sources. Strings in JSON are translatable without touching component code, and a translator never needs to open a file that can break the build.

Adding a template

Add the component under packages/email/src/templates/. Keep it a pure function of its props; anything that needs data should receive it, not fetch it.

Add its strings to all three catalogs. A missing key falls back to the English catalog's value rather than rendering undefined.

Export a render* function from render.ts that takes typed props including locale.

Send it from a background job rather than from the request handler, unless the user is waiting on the result. welcome.ts is the worked example.

Testing without sending

render.test.ts renders each template and asserts on the output, so a broken template fails in CI rather than in somebody's inbox. Run the whole email package with bun test --filter=@workspace/email.

Rendering is also the fastest way to preview: the functions return an HTML string, so writing one to a file and opening it in a browser needs no mail provider and no inbox.

On this page