I live in Switzerland, where a painter in Bern may get customers who speak German, French or English. Most website templates for local businesses are English-only, and adding languages later usually means duplicated pages and broken SEO.
So I built Handwerk, a Nuxt 4 + Tailwind CSS v4 site for trades, with three languages from day one. Here is how it's put together, and what I'd do the same way again.
Live demo: https://handwerk-nuxt-demo.netlify.app
Free Lite version (MIT): https://github.com/polyastavreva/handwerk-nuxt-lite
1. i18n: one config, correct URLs, correct hreflang
I use @nuxtjs/i18n with the prefix_except_default strategy. English lives at /, German at /de, French at /fr:
// nuxt.config.ts
i18n: {
baseUrl: site.url,
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{ code: 'en', language: 'en-GB', name: 'English', file: 'en.json' },
{ code: 'de', language: 'de-CH', name: 'Deutsch', file: 'de.json' },
{ code: 'fr', language: 'fr-CH', name: 'Français', file: 'fr.json' },
],
detectBrowserLanguage: { useCookie: true, cookieKey: 'lang', redirectOn: 'root' },
}
Two details matter for SEO:
-
languagewith a region (de-CH,fr-CH) ends up in thehreflangtags, so Google can show the Swiss German page to Swiss users. -
baseUrlmust be your real domain. Without it, the alternate links are relative and search engines ignore them.
In app.vue one line wires it all up:
const head = useLocaleHead({ seo: true })
useHead(() => ({
htmlAttrs: { lang: head.value.htmlAttrs?.lang },
link: [...(head.value.link || [])],
meta: [...(head.value.meta || [])],
}))
That gives every page <link rel="alternate" hreflang="…"> for all three languages plus x-default.
2. UI strings in JSON, business content in TypeScript
I split text into two kinds:
-
Interface strings (buttons, labels, error messages) live in
i18n/locales/*.json. - Business content (services, prices, projects, reviews, FAQ) lives in typed data files.
The data files use a tiny helper type so every entry must have all three languages:
export type Locale = 'en' | 'de' | 'fr'
export type L<T = string> = Record<Locale, T>
export interface Service {
slug: string
image: string
priceFrom: number
text: L<{ name: string; short: string; intro: string; points: string[] }>
}
If someone adds a service and forgets the French text, TypeScript complains before the site ships. A composable picks the current language:
export function useTx() {
const { locale } = useI18n()
const tx = <T>(obj: L<T>): T => obj[locale.value as Locale] ?? obj.en
return { tx }
}
In templates that reads naturally: {{ tx(service.text).name }}. No CMS, no API, and the whole site still generates as static HTML.
3. The feature local customers actually use: a postcode checker
The first question a visitor has is "do you even work where I live?". So the hero has a small form: type your postcode and get an answer, including whether there's a travel fee.
export const towns: Town[] = [
{ name: 'Bern', postcodes: range(3000, 3030), zone: 'free' },
{ name: 'Thun', postcodes: ['3600', '3602', '3603'], zone: 'fee' },
// …
]
export const findTown = (code: string) =>
towns.find((t) => t.postcodes.includes(code.trim()))
The result is announced in an aria-live region and links straight to the quote form with the postcode prefilled (/contact?pc=3600). It's a few lines of code, and it answers the question that would otherwise be a phone call.
4. Structured data from the same config
All business details live in one site.ts, which also feeds the JSON-LD:
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': site.schemaType, // HousePainter, Electrician, Plumber…
name: site.name,
telephone: site.phone,
address: { '@type': 'PostalAddress', /* … */ },
// aggregateRating: add only if it comes from real reviews
}),
}],
})
Changing schemaType from HousePainter to Electrician is the only thing needed to reuse the site for another trade.
5. Motion without hiding content
For the scroll reveals I used a small directive, but content is only hidden after JavaScript runs:
.reveal-on .reveal { opacity: 0; transform: translateY(28px); transition: … }
.reveal-on .reveal.is-in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.reveal-on .reveal { opacity: 1; transform: none; transition: none; }
}
The plugin adds reveal-on to <html> on the client. If JS fails or the visitor prefers reduced motion, everything is simply visible.
What I'd tell myself at the start
- Decide on languages before writing content. Retrofitting i18n is where most of the time goes.
- Keep business content out of components. Buyers (and clients) edit data files, not Vue templates.
- Region-specific locales (
de-CH) cost nothing and help local SEO.
The Lite version (English, home + service pages + contact) is free and MIT on GitHub: https://github.com/polyastavreva/handwerk-nuxt-lite
The full version with German and French, more pages and the animations is on Gumroad: https://stavreva68.gumroad.com/l/handwerk-nuxt-template
I'd love feedback on the i18n setup, especially from anyone running multilingual Nuxt sites in production.
Top comments (2)
The baseUrl note is easy to miss and important: relative alternates get ignored, so it is worth checking the rendered HTML after each build to confirm the hreflang hrefs are absolute. One question on the locale setup: with language set to de-CH and fr-CH, do the tags also include a plain de and fr? Google's docs describe the language as the required first part and the region as optional, and a German speaker in Germany or Austria may not be matched to de-CH. If Switzerland is the only target market that is a fine choice. If you want German-language visitors elsewhere to land on the German pages, emitting the generic language as well is worth considering.
“Keep business content out of components” is the rule I’d underline too. When you sell code, every decision buyers have to undo inside a component becomes a support question. The L type is a neat touch, making a missing translation a compile error instead of a review comment. Curious about the business side: does the free Lite version on GitHub actually lead people to the full version, or do most of them just stay on Lite?