A Vite plugin that turns Eta templates + locale JSON files into a multi-locale static site. Generates one HTML file per page per locale, plus per-locale sitemaps, 404 pages, and webmanifests. Processes CSS/JS/images with cache-busting hashes and optional WebP conversion.
Designed to be light: the demo site ships under 8 KB total transfer per locale.
Renamed in 3.0.0: previously published as
vite-ssr-nunjucks-i18n-basic(Nunjucks-backed). The 3.x line replaces the templating engine with Eta — see CHANGELOG for the migration guide.
npm install vite-ssr-i18n-basicvite.config.js:
import { defineConfig } from "vite";
import { createMultiLocalePlugin } from "vite-ssr-i18n-basic";
export default defineConfig({
plugins: [
createMultiLocalePlugin({
locales: ["en", "fr"],
defaultLocale: "en",
siteUrl: "https://example.com",
}),
// Vite needs an entry; this gives it one and discards the output.
{
name: "virtual-entry",
resolveId(id) { if (id === "virtual:static-site") return id; },
load(id) { if (id === "virtual:static-site") return ""; },
},
],
publicDir: "public",
build: {
outDir: "dist",
emptyOutDir: false,
rollupOptions: { input: "virtual:static-site", output: { entryFileNames: ".vite/[name].js" } },
},
});Then npm run build and the plugin emits dist/en/, dist/fr/, sitemaps, etc.
.
├── routes.config.json # route table (see below)
├── src/
│ ├── pages/ # *.eta pages (one per route key)
│ ├── layouts/ # base templates (referenced via `<% layout() %>`)
│ ├── partials/ # includes (`<%~ include() %>`)
│ ├── data/
│ │ ├── en.json # locale data — keys consumed by `t()`
│ │ ├── fr.json
│ │ └── meta.json # site-wide non-locale data
│ └── assets/
│ ├── css/ # processed, hashed, manifested
│ ├── js/ # processed, hashed, manifested
│ └── images/ # hashed; png/jpg/jpeg get WebP siblings
└── public/ # copied to dist/ verbatim
All of src/, src/pages, src/layouts, src/partials, src/data must exist or the plugin throws on startup.
{
"hostname": "https://example.com",
"locales": ["en", "fr"],
"basePath": { "en": "/en", "fr": "/fr" },
"routes": [
{ "key": "landing_page", "path": "/", "title": { "en": "Home", "fr": "Accueil" } },
{ "key": "about", "path": { "en": "/about", "fr": "/a-propos" } },
{ "key": "404", "path": "/404", "hidden": true }
]
}keymatches the page filename (src/pages/<key>.eta).pathis appended tobasePath[locale]. Can be a string (same for all locales) or an object per locale.landing_pageis special — used by the 404 and webmanifest generators to determine the per-locale output directory.
src/data/<locale>.json is spread into every template's context. Use nested keys with t():
{
"homepage": { "title": "Welcome", "greeting": "Hello {{name}}!" }
}<%= t('homepage.title') %>
<%= t('homepage.greeting', { name: 'Pierre' }) %>
Missing keys fall back to defaultLocale, then to the key string.
The plugin is configured with useWith: true, so render-context fields are exposed as bare identifiers (no it. prefix). The full Eta reference is at eta.js.org, but in practice you'll only need:
| Eta | Output |
|---|---|
<%= expr %> |
HTML-escaped output |
<%~ expr %> |
Raw (unescaped) output — for trusted HTML, JSON, inlined CSS |
<% code %> |
Run JS, no output (use for if, for, etc.) |
<% layout('/layouts/main') %> |
Wrap this template's render in another layout |
<%~ body %> |
(Inside a layout) inject the child template's output |
<%~ include('/partials/x') %> |
Inline a partial |
All helpers are available as bare identifiers in templates.
| Helper | What it does |
|---|---|
t(key, params?) |
Translate. Supports nested keys and {{param}} interpolation. |
asset(logicalPath) |
Resolve a logical path (e.g. /assets/styles/main.css) to its hashed physical path via the manifest. |
inline_asset(logicalPath) |
Read the processed (minified, hashed) asset's contents and return them inline. Pair with <%~ for critical CSS. |
data_uri(logicalPath) |
Return a data: URI (MIME inferred; SVG URL-encoded, others base64). Pair with <%~ to inline a small LCP image. |
manifest |
The full asset manifest object. |
getLocalizedUrl(path, locale) |
Localized URL for the current page in another locale (hreflang). |
getRouteUrl(key, locale?) |
URL for a route by key. |
isCurrentLocale(locale) |
true when rendering for locale. |
url, absoluteUrl, eq, locale_url |
Small URL helper functions. |
Per-render context also includes: locale, locales, alternates, defaultLocale, rtl, meta (from meta.json), navItems, themeColor, page, plus everything from the active locale data.
<style><%~ inline_asset('/assets/styles/main.css') %></style>
Removes the render-blocking stylesheet request entirely. CSS still lives in src/assets/css/ as an editable source file — the plugin minifies, hashes, and inlines the processed version at template render time.
<img src="<%~ data_uri('/assets/images/avatar.webp') %>" alt="…" width="200" height="200">
The LCP image then ships inside the HTML — no extra request, no extra RTT. Good for tiny images (≤ a few KB). Use sparingly — base64 inflates by ~33 % and is mostly incompressible, so it only pays off for assets small enough to keep your HTML well under the 14 KB initial-window rule.
All options have sensible defaults:
| Option | Default | Notes |
|---|---|---|
srcDir |
"src" |
|
pagesDir |
"src/pages" |
|
layoutsDir |
"src/layouts" |
|
partialsDir |
"src/partials" |
|
dataDir |
"src/data" |
|
outputDir |
"dist" |
Production output. |
devOutputDir |
".tmp" |
Dev mode output (cleaned on exit). |
defaultLocale |
"en" |
Must appear in locales. |
locales |
["en", "fr"] |
|
siteUrl |
"https://example.com" |
Used in sitemaps and absoluteUrl. |
localesMeta |
{} |
Per-locale { name, rtl }. |
emitSitemaps |
true |
|
emit404s |
true |
|
emitWebmanifest |
true |
Set to false to skip per-locale site.webmanifest generation. |
linkRewrite |
"safety-net" |
"off" disables. |
copyPublic |
true |
src/pages/about.eta # default
src/pages/about.fr.eta # used for /fr/
The locale-specific file wins; otherwise the default is used and the missing locale appears in alternates for hreflang.
- CSS:
@importresolved + concatenated, minified in prod, hashed. - JS: minified in prod, hashed. Exposed to templates via
<name>Hashkeys inassetHashes. - Images (
png/jpg/jpeg/webp/avif/svg/gif): in prod, optimized via sharp (sharpis anoptionalDependency— install if you need image processing) and given a WebP sibling. Cached by content hash for incremental builds.
The mapping logical → physical is exposed as the manifest helper.
The src/, routes.config.json, and vite.config.js in this repo are a working demo. npm install && npm run build produces dist/en/ and dist/fr/.
PRs welcome. See CHANGELOG.md for release history.
MIT