Skip to content

Several languages on one site

Take a school that serves every programme page in German and in English:

LanguagePageHandle
German/de/programmes/data-sciencedata-science
English/programmes/data-sciencedata-science
German/de/programmes/psychologiepsychologie
English/programmes/psychologypsychology

Some slugs are translated, and many are not. That is fine. Geoffy publishes content per page, against that page’s own canonical URL, and writes it in the language the page declares. /de/programmes/data-science and /programmes/data-science are two pages to Geoffy, with two sets of content, even though both end in data-science.

Two pages with the same handle need @geoffy/headless 0.3.5 or later, and each page must pass its own canonicalUrl. That is how the component tells the two pages apart.

This recipe is for a site with no commerce platform behind it, where Geoffy reads your own pages. With Shopify behind your storefront, Geoffy publishes each Shopify product against one canonical page, built from your path template.

Step one is in the dashboard: one URL pattern per language

Give Geoffy a pattern for each language. Each pattern reads the pages of one language:

/de/programmes/{handle}
/programmes/{handle}

If your programmes sit one level deeper, give each level its own pattern, with the level written out:

/de/programmes/bachelor/{handle}
/de/programmes/master/{handle}
/programmes/bachelor/{handle}
/programmes/master/{handle}

Prefer that over a named segment such as /de/programmes/{level}/{handle}. A named segment matches every page of that shape, so it also reads a page that is not a programme, such as /de/programmes/dual/partners. The review step lists every page the crawl found, with its handle and its canonical URL. Check that list before you publish anything.

Mount the component on every language’s page, and pass that page’s own values: the handle your URL pattern captures, the locale the route renders, and the canonical URL the page declares.

app/[lang]/programmes/[slug]/page.tsx
import { GeoffyContent } from "@geoffy/headless/next";
import { ProgrammeBody } from "@/components/ProgrammeBody";
const ORIGIN = "https://school.example";
// This site serves English without a prefix and every other language under its own.
// Build the canonical the same way your site already does; do not keep a second copy.
function canonicalFor(lang: string, slug: string) {
return lang === "en"
? `${ORIGIN}/programmes/${slug}`
: `${ORIGIN}/${lang}/programmes/${slug}`;
}
export async function generateMetadata({ params }) {
const { lang, slug } = await params;
return { alternates: { canonical: canonicalFor(lang, slug) } };
}
export default async function ProgrammePage({ params }) {
const { lang, slug } = await params;
return (
<article>
<ProgrammeBody lang={lang} slug={slug} />
<GeoffyContent
siteKey={process.env.GEOFFY_SITE_KEY!}
handle={slug}
locale={lang}
canonicalUrl={canonicalFor(lang, slug)}
/>
</article>
);
}

GeoffyContent is GeoffyProduct under a name for a page that is not a product. Use whichever reads truthfully on your site.

ValueWhy
handleThe segment your URL pattern captured. The same value can occur in several languages
canonicalUrlSelects this page’s content. It must be the page’s own canonical, so it differs per language
localeA second check: content in another language than the page is never shown

What each page gets

PageWidget and structured dataPlain-text copy
/de/programmes/data-scienceThe German content/apps/geoffy/llm/de/programmes/data-science.md
/programmes/data-scienceThe English content/apps/geoffy/llm/programmes/data-science.md
/de/programmes/psychologieThe German content/apps/geoffy/llm/de/programmes/psychologie.md
A page with nothing publishedNothingNo copy (404)

Publishing one page never changes what another page shows, in whichever order you publish. Each plain-text copy lives at its page’s own path under /apps/geoffy/llm/, and names its page as canonical in a Link header. The Geoffy sitemap on your domain lists every copy.

Publish the languages you want

Each page is generated and published on its own, in the language its page declares. Publish the German pages first and the English ones later if that suits you. A page you have not published renders nothing.

Check it

Run these against your deployed site:

Terminal window
# Each page carries its own markup, in the server-rendered HTML
curl -s https://school.example/de/programmes/data-science | grep -c 'data-geoffy'
curl -s https://school.example/programmes/data-science | grep -c 'data-geoffy'
# Each plain-text copy names its own page as canonical
curl -s -D - -o /dev/null https://school.example/apps/geoffy/llm/de/programmes/data-science.md | grep -i '^link'
# link: <https://school.example/de/programmes/data-science>; rel="canonical"

Both counts are greater than zero once both pages are published. A count of zero on a page you published means the package is older than 0.3.5, or the page did not pass its own canonicalUrl.

With an older package

Before 0.3.5 the package asks Geoffy by handle alone. Two pages with one handle then share one answer: the page published last. The other page renders nothing. Upgrade the package; your code does not change.

On a page that declines, the component leaves an inert data-geoffy-skipped marker in view-source, with the reason: canonical-mismatch or language-mismatch.

Everything else (the root files, the namespace, robots.txt, the IndexNow key) is identical. If there is no commerce platform behind your site, read no commerce platform for how Geoffy reads your pages.