Email i18n

Localize Markdown emails with locale-specific template files and ICU message formatting.

Email i18n uses locale-specific Markdown files as the template primitive. Keep one logical email name, such as welcome, and add localized variants under locale folders.

Use server/emails/en/welcome.md and server/emails/fr/welcome.md for the localized variants.

ViteHub resolves the correct variant at runtime from the locale you pass to renderEmail() or sendEmail(). Inside each localized file, you can use ICU message syntax for interpolation, plural rules, and locale-aware number or date formatting.

Enable i18n

Configure email.i18n with the locales you want to support.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vitehub/email/nuxt'],
  email: {
    provider: 'resend',
    apiKey: process.env.RESEND_API_KEY,
    defaults: {
      from: 'Acme <hello@example.com>',
    },
    i18n: {
      enabled: true,
      locales: ['en', 'fr', 'fr-CA'],
      defaultLocale: 'en',
      fallbackLocale: ['en'],
    },
  },
})

Use defaultLocale when you want a locale-specific file to be selected even when the caller does not pass options.locale.

Author localized templates

Create one Markdown file per locale variant.

server/emails/
  welcome.md
  en/welcome.md
  fr/welcome.md

The public email name stays welcome. Locale folders do not become part of the API.

server/emails/en/welcome.md
---
subject: "{count, plural, one {# invitation} other {# invitations}} for {{ name }}"
preheader: "Created {createdAt, date, medium}"
---

# Hello {name}

You have {count, plural, one {# invitation} other {# invitations}} waiting.
server/emails/fr/welcome.md
---
subject: "{count, plural, one {# invitation} other {# invitations}} pour {{ name }}"
preheader: "Créé le {createdAt, date, medium}"
---

# Bonjour {name}

Vous avez {count, plural, one {# invitation} other {# invitations}} en attente.

Use frontmatter for message metadata and use the body for the localized structure. Locale-specific files can reorder blocks, headings, and calls to action when needed.

Render and send localized emails

Pass the locale in the third argument.

await sendEmail('welcome', {
  count: 2,
  createdAt: new Date(),
  name: 'Max',
}, {
  locale: 'fr-CA',
  to: 'max@example.com',
})

renderEmail() accepts the same locale option:

const message = await renderEmail('welcome', {
  count: 1,
  createdAt: new Date(),
  name: 'Max',
}, {
  locale: 'fr',
})

Resolution happens in this order:

  1. The explicit options.locale
  2. The optional email.i18n.detectLocale(...) adapter when available at runtime
  3. email.i18n.defaultLocale
  4. The base non-localized Markdown file

For regional locales, ViteHub also falls back through the language chain. For example, fr-CA tries fr-CA, then fr, then the configured fallback locale, then the base file.

Nuxt i18n auto-detection

When you use Nuxt with both @vitehub/email/nuxt and @nuxtjs/i18n, the email module installs a request-scoped locale bridge automatically when email.i18n is enabled and you did not provide email.i18n.detectLocale yourself.

That bridge does one job only: it picks the localized email variant. It does not call t(), read message catalogs, or share translations with the email templates.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n', '@vitehub/email/nuxt'],
  i18n: {
    experimental: {
      localeDetector: 'server/utils/locale-detector.ts',
    },
  },
  email: {
    provider: 'resend',
    apiKey: process.env.RESEND_API_KEY,
    i18n: {
      enabled: true,
      locales: ['en', 'fr'],
      defaultLocale: 'en',
    },
  },
})

Use defineI18nLocaleDetector(...) for the Nuxt detector so @nuxtjs/i18n can resolve the request locale and the email bridge can reuse it.

server/utils/locale-detector.ts
export default defineI18nLocaleDetector((event) => {
  return event.path.startsWith('/fr') ? 'fr' : 'en'
})

Automatic Nuxt detection applies only when a request event exists, such as API routes, server handlers, and SSR work. Background jobs, queues, cron handlers, and other non-request execution still need an explicit locale or a configured defaultLocale.

ICU message syntax

ViteHub supports ICU formatting in frontmatter string values, Markdown text nodes, and string attributes that already accept interpolation such as href and alt.

server/emails/en/welcome.md
---
subject: "{count, plural, one {# invitation} other {# invitations}}"
---

::button{href="https://example.com/invite/{userId}"}
Open invite
::

Balance: {balance, number, ::currency/USD}

Use ICU syntax for:

  • Variable interpolation such as {name}
  • Plural and select rules
  • Locale-aware date and number formatting

ViteHub still supports the existing {{ name }} interpolation syntax. Use it when you only need simple value insertion. Use ICU syntax when you need translation-aware formatting.

Input schemas across locale variants

All localized variants of the same logical email must use the same embedded input schema. Keep the schema blocks identical across variants, or reuse a shared type and schema implementation.

If localized variants declare conflicting embedded schemas, ViteHub fails the build instead of generating ambiguous types for the canonical email name.