Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

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 page

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: 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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.