Maizzle 6 supports internationalization through vue-i18n. This lets you create email templates in multiple languages using translation keys that resolve to localized strings at build time.
Create a new Maizzle project using this starter:
npx maizzle new maizzle/starter-i18nInstall the dependencies:
cd starter-i18n
npm installTranslations live in locales/, one JSON file per language:
locales/
├── en.json
└── fr.json
locales/en.json (excerpt):
{
"greeting": "Hello there!",
"body": "We're happy to have you on board! ...",
"button": { "label": "Verify email" }
}locales/fr.json (excerpt):
{
"greeting": "Bonjour !",
"body": "Nous sommes heureux de vous compter parmi nous ! ...",
"button": { "label": "Vérifier l’e-mail" }
}maizzle.config.ts registers vue-i18n as a Vue plugin.
Locale files are read instead of being imported, so edits show up in dev through HMR:
// maizzle.config.ts
import { defineConfig } from '@maizzle/framework'
import { readFileSync } from 'node:fs'
import { createI18n } from 'vue-i18n'
const en = JSON.parse(readFileSync('./locales/en.json', 'utf-8'))
const fr = JSON.parse(readFileSync('./locales/fr.json', 'utf-8'))
export default defineConfig({
vue: {
plugins: () => [
createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: { en, fr },
}),
],
},
})A few things worth noting about the config above:
pluginsis a factory (() => [...]), not a plain array.vue-i18nis stateful — settinglocale.valuein one template mutates the shared instance and would leak into the next render. The factory form gives each template a fresh i18n instance.readFileSyncavoids Node's ES module import cache, so editinglocales/*.jsonwhile the dev server runs takes effect on the next render. A plainimport en from './locales/en.json'would be cached and require a server restart.fallbackLocale: 'en'means missing keys in other languages fall back to English instead of rendering the raw key.
The starter ships one template per locale under emails/:
emails/
├── en/
│ └── welcome.vue
└── fr/
└── welcome.vue
emails/en/welcome.vue has no <script setup> — it relies on the default locale ('en') from the config and uses $t() directly in the template:
<template>
<Layout>
<Heading level="1">{{ $t('greeting') }}</Heading>
<Text>{{ $t('body') }}</Text>
<Button href="https://maizzle.com">{{ $t('button.label') }}</Button>
</Layout>
</template>emails/fr/welcome.vue overrides the locale per-template via useI18n():
<script setup>
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
locale.value = 'fr'
</script>
<template>
<Layout>
<Heading level="1">{{ $t('greeting') }}</Heading>
<Text>{{ $t('body') }}</Text>
<Button href="https://maizzle.com">{{ $t('button.label') }}</Button>
</Layout>
</template>Both files share the same translation keys but render into one HTML file per language.
Tip: When using
useI18n(), you can destructuretand use{{ t('greeting') }}instead of{{ $t('greeting') }}. Both work the same —$t()is globally available,t()requires the import.
You'll notice both emails/en/welcome.vue and emails/fr/welcome.vue duplicate the entire markup — only the locale differs. If you don't need them to diverge, extract the shared markup into a component and keep the per-locale files as thin wrappers.
Reorganize like this:
emails/
└── welcome/
├── en.vue
└── fr.vue
components/
└── Welcome.vue
components/Welcome.vue holds the markup and translation keys:
<!-- components/Welcome.vue -->
<template>
<Layout>
<Heading level="1">{{ $t('greeting') }}</Heading>
<Text>{{ $t('body') }}</Text>
<Button href="https://maizzle.com">{{ $t('button.label') }}</Button>
</Layout>
</template>Each wrapper just sets the locale and renders the shared component:
<!-- emails/welcome/en.vue -->
<script setup>
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
locale.value = 'en'
</script>
<template>
<Welcome />
</template><!-- emails/welcome/fr.vue -->
<script setup>
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
locale.value = 'fr'
</script>
<template>
<Welcome />
</template>You still get one HTML file per language in the output, but the markup lives in a single place.
- Create a new locale file, e.g.
locales/ro.json. - Add the import + entry to
maizzle.config.ts:
const ro = JSON.parse(readFileSync('./locales/ro.json', 'utf-8'))
// ...
messages: { en, fr, ro },- Add a wrapper template under
emails/ro/welcome.vuethat setslocale.value = 'ro'in<script setup>and renders the same markup.
Maizzle watches the locales directory by default and re-renders templates when any locale file changes — no extra config needed. If your locale files live somewhere else, point Maizzle at them via server.watch:
// maizzle.config.ts
import { defineConfig } from '@maizzle/framework'
export default defineConfig({
server: {
watch: ['./messages/**/*.json'],
},
})Please open all issues in the framework repository.
The Maizzle framework is open-sourced software licensed under the MIT license.