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. The module also emits the genericdeandfrtags next to them, so German speakers in Germany or Austria still land on the German pages (check the rendered HTML: you should see bothdeandde-CH). -
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: { '@type': 'AggregateRating', ratingValue: 4.9, reviewCount: 137 },
}),
}],
})
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 (0)