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:
| Template | Sent when |
|---|---|
verify-email | A new account needs its address confirmed |
reset-password | A password reset is requested |
welcome | Sign-up completes, queued rather than sent inline |
magic-link | Passwordless sign-in is used |
otp-code | A 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.