This guide covers how to fetch your site configuration, render global layout elements (header, footer), display pages with dynamic data, handle SEO metadata, and build navigation -- everything you need to turn Lynkow into the backbone of your Next.js site.
Prerequisites
You should have the Lynkow SDK installed and a client initialized. If not, see Guide 1: Installation.
// lib/lynkow.ts
import { createClient } from 'lynkow'
export const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
fetchOptions: {
next: { revalidate: 60 },
},
})1. Fetch site config for your layout
The globals.siteConfig() method returns everything you need to build a consistent layout: site metadata, the active locale, and all global blocks (header, footer, or any custom blocks you have defined in the admin).
const { data } = await lynkow.globals.siteConfig()Response shape
{
data: {
site: {
name: string // "My Website"
domain: string // "example.com"
logo: string | null // URL to the site logo
favicon: string | null // URL to the favicon
},
locale: string, // Active locale, e.g. "en"
globals: Record<string, GlobalBlock>
}
}Each GlobalBlock contains:
{
slug: string // "header", "footer", etc.
name: string // Human-readable name
data: Record<string, unknown> // The block's structured data
_warnings?: string[] // Warnings if any DataSource failed to resolve
}The data field structure depends on how you have configured the block schema in the Lynkow admin. For example, a header block might have navigation, logo, and cta fields, while a footer block might have columns, social, and copyright.
2. Layout component
Use globals.siteConfig() in your root layout to fetch everything in a single request. This is a Server Component, so the data is fetched at build time (or revalidated on schedule with ISR).
// app/layout.tsx
import { lynkow } from '@/lib/lynkow'
import { Header } from '@/components/header'
import { Footer } from '@/components/footer'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const { data } = await lynkow.globals.siteConfig()
const header = data.globals['header']
const footer = data.globals['footer']
return (
<html lang={data.locale}>
<head>
{data.site.favicon && (
<link rel="icon" href={data.site.favicon} />
)}
</head>
<body>
{header && <Header data={header.data} siteName={data.site.name} />}
<main>{children}</main>
{footer && <Footer data={footer.data} />}
</body>
</html>
)
}Header component
// components/header.tsx
import Link from 'next/link'
import Image from 'next/image'
interface HeaderData {
logo?: { url: string; alt: string } | null
navigation?: Array<{
label: string
url: string
children?: Array<{ label: string; url: string }>
}>
cta?: { label: string; url: string; variant: 'primary' | 'secondary' } | null
}
export function Header({
data,
siteName,
}: {
data: HeaderData
siteName: string
}) {
return (
<header className="site-header">
<Link href="/">
{data.logo ? (
<Image
src={data.logo.url}
alt={data.logo.alt || siteName}
width={150}
height={50}
priority
/>
) : (
<span>{siteName}</span>
)}
</Link>
<nav>
<ul>
{data.navigation?.map((item, i) => (
<li key={i}>
<Link href={item.url}>{item.label}</Link>
{item.children && item.children.length > 0 && (
<ul className="submenu">
{item.children.map((child, j) => (
<li key={j}>
<Link href={child.url}>{child.label}</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
{data.cta && (
<Link href={data.cta.url} className={`btn btn-${data.cta.variant}`}>
{data.cta.label}
</Link>
)}
</header>
)
}Footer component
// components/footer.tsx
import Link from 'next/link'
interface FooterData {
columns?: Array<{
title: string
links: Array<{ label: string; url: string }>
}>
social?: Array<{ platform: string; url: string }>
copyright?: string
legalLinks?: Array<{ label: string; url: string }>
}
export function Footer({ data }: { data: FooterData }) {
return (
<footer className="site-footer">
{data.columns && data.columns.length > 0 && (
<div className="footer-columns">
{data.columns.map((column, i) => (
<div key={i} className="footer-column">
<h4>{column.title}</h4>
<ul>
{column.links.map((link, j) => (
<li key={j}>
<Link href={link.url}>{link.label}</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
{data.social && data.social.length > 0 && (
<div className="social-links">
{data.social.map((s, i) => (
<a key={i} href={s.url} target="_blank" rel="noopener noreferrer">
{s.platform}
</a>
))}
</div>
)}
<div className="footer-bottom">
{data.copyright && <p>{data.copyright}</p>}
{data.legalLinks?.map((link, i) => (
<Link key={i} href={link.url}>
{link.label}
</Link>
))}
</div>
</footer>
)
}3. Render a page
Create a catch-all route to render any Lynkow page by its URL path.
Using pages.getByPath()
The recommended approach for dynamic routing. The SDK resolves the URL path to the correct page, including nested paths like /services/consulting.
// app/[[...slug]]/page.tsx
import { lynkow } from '@/lib/lynkow'
import { notFound } from 'next/navigation'
import { isLynkowError } from 'lynkow'
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
try {
const page = await lynkow.pages.getByPath(path)
return <PageRenderer slug={page.slug} data={page.data} />
} catch (error) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
notFound()
}
throw error
}
}Using pages.getBySlug()
If you know the exact slug (not the URL path), you can fetch directly:
const page = await lynkow.pages.getBySlug('homepage')Both methods return the same Page object.
The Page response
{
id: number
slug: string // "homepage"
name: string // "Home Page"
path: string | null // "/"
data: Record<string, unknown> // Resolved page data (see section 4)
seo: {
metaTitle?: string
metaDescription?: string
ogTitle?: string
ogDescription?: string
ogImage?: { id: string; url: string; alt?: string } | null
twitterCard?: string
noIndex?: boolean
canonicalUrl?: string
} | null
alternates: Array<{
locale: string
path: string
current: boolean
}>
_warnings?: string[] // Warnings if DataSources failed
}4. Handle DataSource data
The page.data field contains all the structured data defined in the page's schema, with DataSources already resolved by the API. This means if your page schema includes a "latest reviews" DataSource, the actual reviews are embedded directly in page.data.
// components/page-renderer.tsx
import { Homepage } from './pages/homepage'
import { AboutPage } from './pages/about'
import { ContactPage } from './pages/contact'
import { GenericPage } from './pages/generic'
interface PageRendererProps {
slug: string
data: Record<string, unknown>
}
export function PageRenderer({ slug, data }: PageRendererProps) {
switch (slug) {
case 'homepage':
return <Homepage data={data} />
case 'about':
return <AboutPage data={data} />
case 'contact':
return <ContactPage data={data} />
default:
return <GenericPage data={data} />
}
}Example: a homepage with a hero section, features list, and a DataSource for testimonials:
// components/pages/homepage.tsx
import Image from 'next/image'
import Link from 'next/link'
interface HomepageData {
hero: {
title: string
subtitle: string
backgroundImage: { url: string; alt: string } | null
cta: { label: string; url: string } | null
}
features: Array<{
icon: string
title: string
description: string
}>
// DataSource: resolved automatically by the API
testimonials: {
items: Array<{
id: string
authorName: string
rating: number
content: string
}>
meta: { totalCount: number; hasMore: boolean }
}
}
export function Homepage({ data }: { data: HomepageData }) {
return (
<>
<section className="hero">
{data.hero.backgroundImage && (
<Image
src={data.hero.backgroundImage.url}
alt={data.hero.backgroundImage.alt}
fill
priority
/>
)}
<h1>{data.hero.title}</h1>
<p>{data.hero.subtitle}</p>
{data.hero.cta && (
<Link href={data.hero.cta.url} className="btn btn-primary">
{data.hero.cta.label}
</Link>
)}
</section>
{data.features?.length > 0 && (
<section className="features">
{data.features.map((feature, i) => (
<div key={i} className="feature-card">
<span className="feature-icon">{feature.icon}</span>
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</div>
))}
</section>
)}
{data.testimonials?.items?.length > 0 && (
<section className="testimonials">
<h2>What our customers say</h2>
{data.testimonials.items.map((review) => (
<blockquote key={review.id}>
<div className="stars">
{'★'.repeat(review.rating)}
{'☆'.repeat(5 - review.rating)}
</div>
<p>{review.content}</p>
<cite>{review.authorName}</cite>
</blockquote>
))}
</section>
)}
</>
)
}Tip: If a DataSource fails to resolve (e.g., the reviews service is temporarily down), the data will be
nullor empty and the page will include a warning inpage._warnings. Always use optional chaining when accessing DataSource fields.
5. Navigation
Build your site navigation from pages.list(). This returns all published pages with their paths, which you can use to create menus, breadcrumbs, or sitemaps.
// components/page-nav.tsx
import Link from 'next/link'
import { lynkow } from '@/lib/lynkow'
export async function PageNav() {
const { data: pages } = await lynkow.pages.list()
return (
<nav aria-label="Pages">
<ul>
{pages.map((page) => (
<li key={page.slug}>
<Link href={page.path || `/${page.slug}`}>
{page.name}
</Link>
</li>
))}
</ul>
</nav>
)
}Static generation with generateStaticParams
Pre-generate all pages at build time for maximum performance:
// app/[[...slug]]/page.tsx
export async function generateStaticParams() {
const { data: pages } = await lynkow.pages.list()
return pages.map((page) => ({
slug: page.path === '/'
? undefined
: page.path?.split('/').filter(Boolean),
}))
}6. Legal pages
Use the tag filter to fetch pages grouped by purpose. Pages tagged as legal in the Lynkow admin (privacy policy, terms of service, cookie policy, etc.) can be fetched separately:
const { data: legalPages } = await lynkow.pages.list({ tag: 'legal' })Display them in your footer:
// components/legal-links.tsx
import Link from 'next/link'
import { lynkow } from '@/lib/lynkow'
export async function LegalLinks() {
const { data: legalPages } = await lynkow.pages.list({ tag: 'legal' })
if (legalPages.length === 0) return null
return (
<nav aria-label="Legal">
<ul className="legal-links">
{legalPages.map((page) => (
<li key={page.slug}>
<Link href={page.path || `/${page.slug}`}>
{page.name}
</Link>
</li>
))}
</ul>
</nav>
)
}You can use the same pattern for any tag: pages.list({ tag: 'resources' }), pages.list({ tag: 'product' }), etc.
7. SEO meta tags
Use Next.js generateMetadata to set page-level SEO from page.seo. The SDK returns all Open Graph, Twitter Card, and indexing directives you need.
// app/[[...slug]]/page.tsx
import type { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'
import { isLynkowError } from 'lynkow'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug?: string[] }>
}): Promise<Metadata> {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
try {
const page = await lynkow.pages.getByPath(path)
if (!page.seo) return {}
return {
title: page.seo.metaTitle,
description: page.seo.metaDescription,
openGraph: {
title: page.seo.ogTitle || page.seo.metaTitle,
description: page.seo.ogDescription || page.seo.metaDescription,
images: page.seo.ogImage
? [{ url: page.seo.ogImage.url, alt: page.seo.ogImage.alt }]
: [],
},
twitter: page.seo.twitterCard
? { card: page.seo.twitterCard as 'summary' | 'summary_large_image' }
: undefined,
alternates: {
canonical: page.seo.canonicalUrl || undefined,
languages: Object.fromEntries(
page.alternates.map((alt) => [alt.locale, alt.path])
),
},
robots: page.seo.noIndex ? { index: false } : undefined,
}
} catch (error) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
return {}
}
throw error
}
}8. JSON-LD structured data
The API generates Schema.org JSON-LD for each page (Organization, WebPage, BreadcrumbList, etc.). Inject it into the page head for search engine rich results.
Fetch JSON-LD
const jsonLd = await lynkow.pages.getJsonLd('homepage')
// Returns an array of schema objects, e.g.:
// [
// { "@type": "Organization", "name": "...", "url": "..." },
// { "@type": "WebPage", "name": "Home Page", "description": "..." },
// { "@type": "BreadcrumbList", "itemListElement": [...] }
// ]JsonLd component
// components/json-ld.tsx
export function JsonLd({ schemas }: { schemas: object[] }) {
if (!schemas || schemas.length === 0) return null
return (
<>
{schemas.map((schema, i) => (
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
))}
</>
)
}Use in the page component
// app/[[...slug]]/page.tsx
import { JsonLd } from '@/components/json-ld'
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
try {
const page = await lynkow.pages.getByPath(path)
const jsonLd = await lynkow.pages.getJsonLd(page.slug)
return (
<>
<JsonLd schemas={jsonLd} />
<PageRenderer slug={page.slug} data={page.data} />
</>
)
} catch (error) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
notFound()
}
throw error
}
}9. Locale switcher
Each page includes an alternates array listing all available translations with their paths. Use this to build a language switcher.
// components/locale-switcher.tsx
import Link from 'next/link'
interface Alternate {
locale: string
path: string
current: boolean
}
const LOCALE_LABELS: Record<string, string> = {
en: 'English',
fr: 'Francais',
es: 'Espanol',
de: 'Deutsch',
pt: 'Portugues',
}
export function LocaleSwitcher({ alternates }: { alternates: Alternate[] }) {
if (alternates.length <= 1) return null
return (
<nav aria-label="Language">
<ul className="locale-switcher">
{alternates.map((alt) => (
<li key={alt.locale}>
{alt.current ? (
<span aria-current="page">
{LOCALE_LABELS[alt.locale] || alt.locale}
</span>
) : (
<Link href={alt.path} hrefLang={alt.locale}>
{LOCALE_LABELS[alt.locale] || alt.locale}
</Link>
)}
</li>
))}
</ul>
</nav>
)
}Pass alternates from your page component:
export default async function Page({ params }) {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
const page = await lynkow.pages.getByPath(path)
return (
<>
<LocaleSwitcher alternates={page.alternates} />
<PageRenderer slug={page.slug} data={page.data} />
</>
)
}10. 404 handling
When a page is not found, the SDK throws a LynkowError with code NOT_FOUND. Use the isLynkowError type guard to detect this and trigger Next.js's built-in 404 page.
// app/[[...slug]]/page.tsx
import { lynkow } from '@/lib/lynkow'
import { notFound } from 'next/navigation'
import { isLynkowError } from 'lynkow'
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
try {
const page = await lynkow.pages.getByPath(path)
return <PageRenderer slug={page.slug} data={page.data} />
} catch (error) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
notFound()
}
// Re-throw other errors (500, network issues, etc.)
// so they are caught by the nearest error.tsx boundary
throw error
}
}Create a custom 404 page:
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="not-found">
<h1>404</h1>
<p>The page you are looking for does not exist.</p>
<Link href="/">Go back home</Link>
</div>
)
}Complete example: full page route
Here is the complete catch-all page route combining all the patterns from this guide:
// app/[[...slug]]/page.tsx
import type { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'
import { notFound } from 'next/navigation'
import { isLynkowError } from 'lynkow'
import { JsonLd } from '@/components/json-ld'
import { LocaleSwitcher } from '@/components/locale-switcher'
import { PageRenderer } from '@/components/page-renderer'
// Static generation: pre-build all known pages
export async function generateStaticParams() {
const { data: pages } = await lynkow.pages.list()
return pages.map((page) => ({
slug: page.path === '/'
? undefined
: page.path?.split('/').filter(Boolean),
}))
}
// SEO metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ slug?: string[] }>
}): Promise<Metadata> {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
try {
const page = await lynkow.pages.getByPath(path)
if (!page.seo) return {}
return {
title: page.seo.metaTitle,
description: page.seo.metaDescription,
openGraph: {
title: page.seo.ogTitle || page.seo.metaTitle,
description: page.seo.ogDescription || page.seo.metaDescription,
images: page.seo.ogImage
? [{ url: page.seo.ogImage.url, alt: page.seo.ogImage.alt }]
: [],
},
twitter: page.seo.twitterCard
? { card: page.seo.twitterCard as 'summary' | 'summary_large_image' }
: undefined,
alternates: {
canonical: page.seo.canonicalUrl || undefined,
languages: Object.fromEntries(
page.alternates.map((alt) => [alt.locale, alt.path])
),
},
robots: page.seo.noIndex ? { index: false } : undefined,
}
} catch {
return {}
}
}
// Page component
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const path = slug ? `/${slug.join('/')}` : '/'
try {
const page = await lynkow.pages.getByPath(path)
const jsonLd = await lynkow.pages.getJsonLd(page.slug)
return (
<>
<JsonLd schemas={jsonLd} />
<LocaleSwitcher alternates={page.alternates} />
<PageRenderer slug={page.slug} data={page.data} />
</>
)
} catch (error) {
if (isLynkowError(error) && error.code === 'NOT_FOUND') {
notFound()
}
throw error
}
}Cache behavior
Method | SDK internal TTL |
|---|---|
| 10 minutes |
| 5 minutes |
| 5 minutes |
| 5 minutes |
| 5 minutes |
To manually clear the SDK's internal cache:
lynkow.globals.clearCache()
lynkow.pages.clearCache()Note: These TTLs are for the SDK's internal cache. The Next.js data cache is controlled separately via
fetchOptions.next.revalidateset when creating the client (see Installation guide).