DEV Community

Cover image for How I built a multilingual Nuxt 4 site for local businesses (EN/DE/FR, hreflang, no CMS)
Pavlina Stavreva
Pavlina Stavreva

Posted on Edited on AI-assisted

How I built a multilingual Nuxt 4 site for local businesses (EN/DE/FR, hreflang, no CMS)

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' },
}
Enter fullscreen mode Exit fullscreen mode

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. The module also emits the generic de and fr tags next to them, so German speakers in Germany or Austria still land on the German pages (check the rendered HTML: you should see both de and de-CH).
  • 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 || [])],
}))
Enter fullscreen mode Exit fullscreen mode

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[] }>
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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()))
Enter fullscreen mode Exit fullscreen mode

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 },
    }),
  }],
})
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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)