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:

  • language with a region (de-CH, fr-CH) ends up in the hreflang tags, so Google can show the Swiss German page to Swiss users.
  • baseUrl must 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.