Build a fully internationalized website with Lynkow's locale system. This guide covers locale configuration, per-request overrides, hreflang tags, locale-aware static generation, and a complete Next.js i18n layout.
1. Setup
Install and configure the client with a default locale
npm install @lynkow/sdkSet your environment variables:
# .env.local
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-id
NEXT_PUBLIC_LYNKOW_DEFAULT_LOCALE=enCreate the client with a default locale:
// lib/lynkow.ts
import { createClient } from '@lynkow/sdk'
export const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
locale: process.env.NEXT_PUBLIC_LYNKOW_DEFAULT_LOCALE || 'en',
})Once initialized, you can inspect the locale state at any time:
console.log(lynkow.locale) // 'en'
console.log(lynkow.availableLocales) // ['en', 'fr', 'es', 'de', 'pt']Browser auto-detection
In browser environments, the SDK automatically detects the user's preferred locale using the following priority order:
localStorage(from a previoussetLocale()call)URL query parameter
?locale=frHTML
langattribute on<html>Site default locale
This means returning visitors will see content in their last selected language without any extra code.
2. Per-request locale
You can override the client's default locale on any individual API call without changing the global setting. This is useful for server-side rendering where you know the target locale from the URL.
// Fetch content in French, regardless of the client's default locale
const articles = await lynkow.contents.list({ locale: 'fr' })
// Fetch a single content item in German
const post = await lynkow.contents.getBySlug('hello-world', { locale: 'de' })To change the global locale (e.g., when a user switches language in the UI):
lynkow.setLocale('fr')
// All subsequent calls now use French by default
const articles = await lynkow.contents.list()Server components with locale from params
In Next.js App Router, extract the locale from the URL segment and pass it per-request:
// app/[locale]/blog/page.tsx
import { lynkow } from '@/lib/lynkow'
interface Props {
params: Promise<{ locale: string }>
}
export default async function BlogPage({ params }: Props) {
const { locale } = await params
const { data: posts } = await lynkow.contents.list({
locale,
type: 'blog-post',
})
return (
<main>
<h1>{locale === 'fr' ? 'Articles' : 'Blog Posts'}</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/${locale}/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
)
}3. Locale switcher component
Build a locale switcher that links to the correct alternate URL for each language. The data source depends on the content type:
Pages expose
alternatesdirectly on the page object.Content items expose alternates via
structuredData.alternates.
// components/locale-switcher.tsx
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
interface Alternate {
locale: string
url?: string
path?: string
current?: boolean
}
interface LocaleSwitcherProps {
alternates: Alternate[]
currentLocale: string
}
const LOCALE_LABELS: Record<string, string> = {
en: 'English',
fr: 'Francais',
es: 'Espanol',
de: 'Deutsch',
pt: 'Portugues',
}
export function LocaleSwitcher({ alternates, currentLocale }: LocaleSwitcherProps) {
if (!alternates || alternates.length === 0) {
return null
}
return (
<nav aria-label="Language switcher">
<ul className="flex gap-2">
{alternates.map((alt) => {
const href = alt.path || alt.url || '#'
const isCurrent = alt.locale === currentLocale || alt.current
return (
<li key={alt.locale}>
{isCurrent ? (
<span className="font-bold" aria-current="page">
{LOCALE_LABELS[alt.locale] || alt.locale}
</span>
) : (
<Link href={href} hrefLang={alt.locale}>
{LOCALE_LABELS[alt.locale] || alt.locale}
</Link>
)}
</li>
)
})}
</ul>
</nav>
)
}Using with a page
// app/[locale]/[...slug]/page.tsx
import { lynkow } from '@/lib/lynkow'
import { LocaleSwitcher } from '@/components/locale-switcher'
interface Props {
params: Promise<{ locale: string; slug: string[] }>
}
export default async function CatchAllPage({ params }: Props) {
const { locale, slug } = await params
const urlPath = `/${slug.join('/')}`
const resolved = await lynkow.paths.resolve(urlPath)
if (resolved.type === 'content' && resolved.content) {
const content = resolved.content
const alternates = content.structuredData?.alternates || []
return (
<article>
<LocaleSwitcher alternates={alternates} currentLocale={locale} />
<h1>{content.title}</h1>
{/* Render content body */}
</article>
)
}
return <div>Not found</div>
}Using with a page object
// When working with Lynkow Page objects (not Content)
const page = await lynkow.pages.getByPath('/about')
// Page.alternates has a slightly different shape: { locale, path, current }
const alternates = page.alternates || []
// Pass directly to the switcher
<LocaleSwitcher alternates={alternates} currentLocale={locale} />4. Generate hreflang tags
Hreflang tags tell search engines which language versions of a page exist. Generate them from the same alternates data.
// components/hreflang-tags.tsx
interface Alternate {
locale: string
url?: string
path?: string
}
interface HreflangTagsProps {
alternates: Alternate[]
baseUrl: string
}
export function generateHreflangLinks(
alternates: Alternate[],
baseUrl: string
): Array<{ rel: string; hrefLang: string; href: string }> {
const links = alternates.map((alt) => ({
rel: 'alternate',
hrefLang: alt.locale,
href: alt.url || `${baseUrl}${alt.path}`,
}))
// Add x-default pointing to the first alternate (usually the default locale)
if (links.length > 0) {
links.push({
rel: 'alternate',
hrefLang: 'x-default',
href: links[0].href,
})
}
return links
}Using in generateMetadata
// app/[locale]/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'
import { generateHreflangLinks } from '@/components/hreflang-tags'
const BASE_URL = 'https://example.com'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
const content = await lynkow.contents.getBySlug(slug, { locale })
const alternates = content.structuredData?.alternates || []
const languages: Record<string, string> = {}
for (const alt of alternates) {
languages[alt.locale] = alt.url || `${BASE_URL}/${alt.locale}/blog/${slug}`
}
return {
title: content.metaTitle || content.title,
description: content.metaDescription,
alternates: {
canonical: content.canonicalUrl || `${BASE_URL}/${locale}/blog/${slug}`,
languages,
},
}
}This produces the following HTML in <head>:
<link rel="alternate" hreflang="en" href="https://example.com/en/blog/hello-world" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/hello-world" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/blog/hello-world" />5. Static generation per locale
Use paths.list() with a locale parameter to generate static pages for every locale at build time.
// app/[locale]/[...slug]/page.tsx
import { lynkow } from '@/lib/lynkow'
export async function generateStaticParams() {
const locales = lynkow.availableLocales // ['en', 'fr', 'es', ...]
const params: Array<{ locale: string; slug: string[] }> = []
for (const locale of locales) {
const { paths } = await lynkow.paths.list({ locale })
for (const p of paths) {
// p is a URL path like '/blog/my-post' or '/about'
const segments = p.replace(/^\//, '').split('/')
params.push({ locale, slug: segments })
}
}
return params
}Blog-specific static generation
If you only need blog paths:
// app/[locale]/blog/[slug]/page.tsx
import { lynkow } from '@/lib/lynkow'
export async function generateStaticParams() {
const locales = lynkow.availableLocales
const params: Array<{ locale: string; slug: string }> = []
for (const locale of locales) {
const { data: posts } = await lynkow.contents.list({
locale,
type: 'blog-post',
perPage: 1000,
})
for (const post of posts) {
params.push({ locale, slug: post.slug })
}
}
return params
}6. Next.js middleware for locale routing
Redirect users to the correct locale prefix based on their browser preferences or a stored preference.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'fr', 'es', 'de', 'pt']
const DEFAULT_LOCALE = 'en'
function getPreferredLocale(request: NextRequest): string {
// Check cookie for stored preference
const cookieLocale = request.cookies.get('locale')?.value
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale
}
// Parse Accept-Language header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map((lang) => {
const [code, priority] = lang.trim().split(';q=')
return {
code: code.split('-')[0].toLowerCase(),
priority: priority ? parseFloat(priority) : 1.0,
}
})
.sort((a, b) => b.priority - a.priority)
for (const { code } of preferred) {
if (SUPPORTED_LOCALES.includes(code)) {
return code
}
}
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip static files and API routes
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.includes('.') // static files
) {
return NextResponse.next()
}
// Check if the pathname already starts with a supported locale
const pathnameLocale = SUPPORTED_LOCALES.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameLocale) {
// Locale is already in the URL, set it as a cookie for future visits
const response = NextResponse.next()
response.cookies.set('locale', pathnameLocale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
})
return response
}
// No locale in URL: redirect to the preferred locale
const locale = getPreferredLocale(request)
const url = request.nextUrl.clone()
url.pathname = `/${locale}${pathname}`
const response = NextResponse.redirect(url)
response.cookies.set('locale', locale, {
maxAge: 60 * 60 * 24 * 365,
path: '/',
})
return response
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico|robots.txt|sitemap.xml).*)'],
}7. URL patterns
Lynkow uses locale-prefixed URL paths. Here is how locale appears across different route types:
Route type | URL pattern | Example |
|---|---|---|
Home page |
|
|
Blog listing |
|
|
Blog post |
|
|
Page |
|
|
Category |
|
|
Slug behavior across locales
Content slugs can differ between locales. A blog post might have:
English:
/en/blog/getting-startedFrench:
/fr/blog/premiers-pas
The alternates array on content and pages always provides the correct slug for each locale. Never construct cross-locale URLs manually --- always use the alternates data.
Resolving paths
When a request comes in, use paths.resolve() to determine what content to render:
const result = await lynkow.paths.resolve('/blog/getting-started')
if (result.type === 'content') {
// Render content.title, content.body, etc.
console.log(result.content)
}
if (result.type === 'category') {
// Render category page with child contents
console.log(result.category, result.contents)
}Handling redirects
Check for redirects before rendering to handle moved or renamed content:
const redirect = await lynkow.paths.matchRedirect('/old-path')
if (redirect) {
// redirect.target — destination URL
// redirect.statusCode — 301 or 302
return NextResponse.redirect(redirect.target, redirect.statusCode)
}8. Cache behavior
The SDK maintains an in-memory cache for API responses. Locale changes interact with this cache in specific ways:
Calling setLocale() clears the entire cache. This ensures that subsequent requests return content in the new locale rather than serving stale cached data from the previous locale.
// Cache is populated with English content
await lynkow.contents.list() // fetches and caches EN data
// Switching locale clears all cached data
lynkow.setLocale('fr')
// This makes a fresh API call (cache was cleared)
await lynkow.contents.list() // fetches and caches FR dataPer-request locale overrides do not clear the cache. They fetch locale-specific data alongside the default locale cache:
lynkow.setLocale('en')
// Cached under the 'en' locale key
await lynkow.contents.list()
// Fetched separately, cached under 'fr' locale key. Does not evict 'en' cache.
await lynkow.contents.list({ locale: 'fr' })Recommendation for server-side rendering: Use per-request locale overrides instead of setLocale(). This avoids cache thrashing when serving requests for multiple locales concurrently:
// Preferred in server components
const posts = await lynkow.contents.list({ locale: params.locale })
// Avoid in server contexts where multiple locales are served
// lynkow.setLocale(params.locale) // clears cache on every request9. Complete i18n layout example
This section ties everything together into a working Next.js App Router setup with locale-prefixed routes, middleware redirection, and a locale switcher.
File structure
app/
[locale]/
layout.tsx
page.tsx
blog/
page.tsx
[slug]/
page.tsx
components/
locale-switcher.tsx
hreflang-tags.tsx
lib/
lynkow.ts
middleware.tsRoot layout
// app/[locale]/layout.tsx
import { ReactNode } from 'react'
import { lynkow } from '@/lib/lynkow'
import { LocaleSwitcher } from '@/components/locale-switcher'
interface Props {
children: ReactNode
params: Promise<{ locale: string }>
}
export async function generateStaticParams() {
return lynkow.availableLocales.map((locale) => ({ locale }))
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params
return (
<html lang={locale} dir="ltr">
<body>
<header className="flex items-center justify-between p-4 border-b">
<nav>
<a href={`/${locale}`}>Home</a>
<a href={`/${locale}/blog`} className="ml-4">Blog</a>
</nav>
<LocaleSwitcher
alternates={lynkow.availableLocales.map((loc) => ({
locale: loc,
path: `/${loc}`,
current: loc === locale,
}))}
currentLocale={locale}
/>
</header>
<main>{children}</main>
</body>
</html>
)
}Home page
// app/[locale]/page.tsx
import { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'
const BASE_URL = 'https://example.com'
interface Props {
params: Promise<{ locale: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params
const languages: Record<string, string> = {}
for (const loc of lynkow.availableLocales) {
languages[loc] = `${BASE_URL}/${loc}`
}
return {
title: locale === 'fr' ? 'Accueil' : 'Home',
alternates: {
canonical: `${BASE_URL}/${locale}`,
languages,
},
}
}
export default async function HomePage({ params }: Props) {
const { locale } = await params
const { data: featured } = await lynkow.contents.list({
locale,
type: 'blog-post',
perPage: 3,
sort: '-publishedAt',
})
return (
<section className="p-8">
<h1>{locale === 'fr' ? 'Bienvenue' : 'Welcome'}</h1>
<div className="grid grid-cols-3 gap-4 mt-6">
{featured.map((post) => (
<article key={post.id} className="border rounded p-4">
<h2>{post.title}</h2>
<p>{post.metaDescription}</p>
<a href={`/${locale}/blog/${post.slug}`}>
{locale === 'fr' ? 'Lire la suite' : 'Read more'}
</a>
</article>
))}
</div>
</section>
)
}Blog post page with full i18n support
// app/[locale]/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { LocaleSwitcher } from '@/components/locale-switcher'
const BASE_URL = 'https://example.com'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateStaticParams() {
const locales = lynkow.availableLocales
const params: Array<{ locale: string; slug: string }> = []
for (const locale of locales) {
const { data: posts } = await lynkow.contents.list({
locale,
type: 'blog-post',
perPage: 1000,
})
for (const post of posts) {
params.push({ locale, slug: post.slug })
}
}
return params
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
try {
const content = await lynkow.contents.getBySlug(slug, { locale })
const alternates = content.structuredData?.alternates || []
const languages: Record<string, string> = {}
for (const alt of alternates) {
languages[alt.locale] = alt.url || `${BASE_URL}/${alt.locale}/blog/${slug}`
}
return {
title: content.metaTitle || content.title,
description: content.metaDescription,
keywords: content.keywords,
alternates: {
canonical: content.canonicalUrl || `${BASE_URL}/${locale}/blog/${slug}`,
languages,
},
openGraph: {
title: content.metaTitle || content.title,
description: content.metaDescription || undefined,
images: content.ogImage ? [{ url: content.ogImage }] : undefined,
locale,
alternateLocale: alternates
.filter((alt) => alt.locale !== locale)
.map((alt) => alt.locale),
},
}
} catch {
return { title: 'Not Found' }
}
}
export default async function BlogPostPage({ params }: Props) {
const { locale, slug } = await params
let content
try {
content = await lynkow.contents.getBySlug(slug, { locale })
} catch {
notFound()
}
const alternates = content.structuredData?.alternates || []
return (
<article className="max-w-3xl mx-auto p-8">
<LocaleSwitcher alternates={alternates} currentLocale={locale} />
<h1 className="text-4xl font-bold mt-4">{content.title}</h1>
{content.metaDescription && (
<p className="text-lg text-gray-600 mt-2">{content.metaDescription}</p>
)}
<div className="prose mt-8">
{/* Render your content body here */}
</div>
{/* Inject JSON-LD for SEO */}
{content.structuredData?.article?.jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(content.structuredData.article.jsonLd),
}}
/>
)}
</article>
)
}Catch-all page with redirect handling
// app/[locale]/[...slug]/page.tsx
import { notFound, redirect } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { LocaleSwitcher } from '@/components/locale-switcher'
interface Props {
params: Promise<{ locale: string; slug: string[] }>
}
export async function generateStaticParams() {
const locales = lynkow.availableLocales
const params: Array<{ locale: string; slug: string[] }> = []
for (const locale of locales) {
const { paths } = await lynkow.paths.list({ locale })
for (const p of paths) {
const segments = p.replace(/^\//, '').split('/')
params.push({ locale, slug: segments })
}
}
return params
}
export default async function CatchAllPage({ params }: Props) {
const { locale, slug } = await params
const urlPath = `/${slug.join('/')}`
// Check for redirects first
const redirectMatch = await lynkow.paths.matchRedirect(urlPath)
if (redirectMatch) {
redirect(redirectMatch.target)
}
const resolved = await lynkow.paths.resolve(urlPath)
if (resolved.type === 'content' && resolved.content) {
const content = resolved.content
const alternates = content.structuredData?.alternates || []
return (
<article className="max-w-3xl mx-auto p-8">
<LocaleSwitcher alternates={alternates} currentLocale={locale} />
<h1 className="text-4xl font-bold">{content.title}</h1>
<div className="prose mt-8">
{/* Render content body */}
</div>
</article>
)
}
if (resolved.type === 'category' && resolved.category) {
return (
<section className="max-w-5xl mx-auto p-8">
<h1 className="text-4xl font-bold">{resolved.category.name}</h1>
<div className="grid grid-cols-2 gap-4 mt-6">
{resolved.contents?.map((item) => (
<article key={item.id} className="border rounded p-4">
<h2>{item.title}</h2>
<a href={`/${locale}/${item.slug}`}>
{locale === 'fr' ? 'Lire' : 'Read'}
</a>
</article>
))}
</div>
</section>
)
}
notFound()
}Summary
Task | API | Notes |
|---|---|---|
Set default locale |
| Applied to all requests |
Read current locale |
| |
List available locales |
| From site configuration |
Change locale at runtime |
| Clears cache |
Override per request |
| Does not affect global state |
Page alternates |
|
|
Content alternates |
|
|
Generate paths per locale |
| For |
Resolve a URL path |
| Returns content or category |
Check for redirects |
| Returns redirect or null |