How multi-locale Framer sites work in Next.js

Multi-locale Framer sites in Next.js: one route tree, locale routing in proxy.ts, translation files, per-locale metadata and hreflang.

Published
Reading time
6 min
Deframer cover: “One site, every language.” Beside it, a list of English, Deutsch, Français and Español with Deutsch selected
On this page8 sections

When Deframer converts a multi-locale Framer site, every language is served from a single Next.js App Router tree, src/app/[locale]/, where the locale segment is a locale code. A generated src/proxy.ts maps prefixed URLs such as /ja/… and unprefixed default-locale URLs onto that tree, translations live in src/locales/, and each page's title, description and language versions are stored per locale in a metadata.json next to its route. The result is a TypeScript project that publishes the same URLs, in the same languages, as the Framer site did.

This post walks through each of those pieces, then covers the part that takes the most care: adding a new locale after the conversion.

The route tree: one folder for every language

A single-locale project has one page.tsx per URL directly under src/app/. A multi-locale project nests everything one level deeper:

Code
src/app/[locale]/
  layout.tsx                 root layout: <html lang>, the router in that locale
  page.tsx                   /  (default locale)  and  /en/
  kontakt/page.tsx           /kontakt  and  /en/kontakt   + metadata.json
  projects/[slug]/page.tsx   every CMS item in each locale, by its slug there
  not-found.tsx              the 404 page in the URL's locale

The folder names follow the site's paths, so a German-first site keeps kontakt rather than having it renamed. Where a locale uses its own localized path for a page, that route re-exports the shared page, so the page component exists once.

Because every locale shares a tree, a layout or component change applies to all languages at once. Only text, metadata and CMS records differ per locale.

How src/proxy.ts routes locales

Next.js 16 replaced middleware.ts with proxy.ts, and every converted project has one. On a multi-locale site it does three jobs that file-system routes can't express on their own:

  1. Maps URLs onto [locale]. It reads the URL's prefix (from a table of each locale's prefix, keyed by locale code; the default locale has none) and rewrites the request onto src/app/[locale]/…. Visitors keep seeing /ja/about; Next.js renders the [locale] route with the matching code.
  2. Keeps one URL form per page. A locale's home page answers with a trailing slash (/en/), other pages as published, and the other form redirects with a 308. That is why next.config.ts sets skipTrailingSlashRedirect: true.
  3. Serves the 404 page in the right language. An unknown URL is rewritten to the 404 page of the URL's locale with status 404, so a visitor on /ja/missing sees the Japanese 404.

The published URLs per locale are listed in src/site/pages.json. The proxy consults that list, which matters when you add pages later: a new page that isn't in pages.json for a locale will answer 404 in that locale.

Translations in src/locales/

Translations mirror the file they translate. Text for src/views/home.tsx lives under src/locales/views/home.ts plus one file per locale; text for src/components/footer.tsx lives under src/locales/components/footer.ts. To change a German heading on the home page, you open the German file for the home view and edit the string. No lookup by opaque ids, no editor round trip.

Two practical tips:

  • Search the project for the text you want to change. The match tells you which locale file holds it.
  • Keep translation edits in their own commits, so reviewers can see that a change is copy only. If you're new to working this way, maintaining a converted site with Git covers a simple branch-per-change routine.

Per-locale metadata and hreflang

Each localized route has a metadata.json beside it, evaluated at conversion time. The route reads it with the Next.js Metadata API, by locale:

tsx
// Title, description, social image and language versions of this page in each locale, evaluated at conversion time.
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  return (metadata as Record<string, Metadata>)[(await params).locale] ?? {};
}

Each locale's entry holds the page's title, description, Open Graph and Twitter data, its canonical URL and alternates.languages: a map from locale code to that page's URL in each language, plus x-default pointing at the default locale's version. Next.js renders alternates.languages as <link rel="alternate" hreflang="…"> tags, so search engines see the same language relationships the Framer site published.

An entry looks roughly like this (illustrative; open your own metadata.json for the exact keys and URLs):

json
{
  "en": {
    "title": "Contact",
    "description": "Get in touch with the studio.",
    "alternates": {
      "canonical": "/en/kontakt",
      "languages": { "de": "/kontakt", "en": "/en/kontakt", "x-default": "/kontakt" }
    }
  }
}

If you change a title, change it here for the locale in question. If the site moves to a new domain, update metadataBase in the root layout so canonical and alternate URLs resolve against the new host. The SEO guide for moving off Framer covers that cutover in detail.

CMS content per locale

Framer CMS collections become src/cms/<collection>.json plus a typed src/cms/<collection>.ts. On a multi-locale site, a locale gets its own file, such as blog.ja-jp.json, only when its records differ from the default. Untranslated collections stay as one file.

CMS detail pages list every item in each locale by its slug there, through generateStaticParams, and read item metadata from metadata.json keyed by locale and slug:

tsx
export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }): Promise<Metadata> {
  const { locale, slug } = await params;
  return (metadata as Record<string, Record<string, Metadata>>)[locale]?.[decodeURIComponent(slug)] ?? {};
}

So adding a translated post means editing the locale's JSON record, adding its { locale, slug } pair to generateStaticParams, adding its metadata entry, and adding its path to src/site/pages.json. The CMS to JSON guide explains the record format and rich text.

Sitemaps

Per-locale sitemaps are copied into public/ exactly as the site published them. They are static files: if you add pages or change domains, edit them (or replace them with a generated sitemap.ts that you add yourself).

Adding a locale

There are two honest routes.

Option A: add it in Framer and convert again. If the site is still maintained in Framer, add the locale there, publish, and run a new conversion. Converting is free and downloading the new project uses a credit (see pricing), but the routes, translations, metadata and sitemaps all arrive consistent.

Option B: add it by hand in the project. This is a code change across several files. Mirror an existing locale exactly:

  1. Add the locale to the locales listed in src/site/routes.ts.
  2. Add its URL prefix to the prefix table in src/proxy.ts.
  3. Add its published paths to src/site/pages.json.
  4. Create a translation file for each file in src/locales/ that has one per locale.
  5. Add an entry for the locale in every route's metadata.json, and add the new language to each page's alternates.languages in the other locales too, so hreflang stays reciprocal.
  6. Add per-locale CMS files where the content differs, plus the { locale, slug } pairs in each detail route's generateStaticParams.
  7. Update or add the locale's sitemap in public/.

Then test:

sh
npm run build && npm run start
curl -I http://localhost:3000/fr/
curl -I http://localhost:3000/fr/does-not-exist

The first should answer 200, the second 404 with the French 404 page. View the page source to confirm <html lang> and the hreflang links.

FAQ

Does Deframer keep my localized URLs?

Yes. The proxy and src/site/pages.json reproduce each locale's published paths, including localized paths, trailing-slash forms and the 404 page per locale.

Where do I edit a translation?

In src/locales/, in the file that mirrors the view or component you're changing, one file per locale. Searching the project for the current text is the quickest way to find it.

Are hreflang tags generated?

Yes. Each page's metadata.json includes alternates.languages per locale, with an x-default, and Next.js renders them as alternate links.

Do I need a separate JSON file for every locale's CMS content?

No. A per-locale file such as blog.ja-jp.json exists only when that locale's records differ from the default.

Can I add a locale without converting again?

Yes, but it touches routes, the proxy, pages.json, translations, metadata, CMS and sitemaps, so mirror an existing locale carefully. If the site is still edited in Framer, adding the locale there and converting again is often simpler.