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

Bash
npm install @lynkow/sdk

Set your environment variables:

env
# .env.local
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-id
NEXT_PUBLIC_LYNKOW_DEFAULT_LOCALE=en

Create the client with a default locale:

TypeScript
// 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:

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

  1. localStorage (from a previous setLocale() call)

  2. URL query parameter ?locale=fr

  3. HTML lang attribute on <html>

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

TypeScript
// 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):

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

TypeScript
// 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 alternates directly on the page object.

  • Content items expose alternates via structuredData.alternates.

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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.

TypeScript
// 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

TypeScript
// 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>:

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

TypeScript
// 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:

TypeScript
// 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.

TypeScript
// 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

/{locale}

/fr

Blog listing

/{locale}/blog

/en/blog

Blog post

/{locale}/blog/{slug}

/fr/blog/mon-article

Page

/{locale}/{path}

/de/ueber-uns

Category

/{locale}/{category-path}

/es/tecnologia

Slug behavior across locales

Content slugs can differ between locales. A blog post might have:

  • English: /en/blog/getting-started

  • French: /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:

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

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

TypeScript
// 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 data

Per-request locale overrides do not clear the cache. They fetch locale-specific data alongside the default locale cache:

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

TypeScript
// 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 request

9. 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.ts

Root layout

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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

createClient({ locale })

Applied to all requests

Read current locale

lynkow.locale

List available locales

lynkow.availableLocales

From site configuration

Change locale at runtime

lynkow.setLocale('fr')

Clears cache

Override per request

contents.list({ locale: 'fr' })

Does not affect global state

Page alternates

page.alternates

[{ locale, path, current }]

Content alternates

content.structuredData.alternates

[{ locale, url }]

Generate paths per locale

paths.list({ locale })

For generateStaticParams

Resolve a URL path

paths.resolve(urlPath)

Returns content or category

Check for redirects

paths.matchRedirect(urlPath)

Returns redirect or null