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: { '@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.










