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.

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

TypeScript
const { data } = await lynkow.globals.siteConfig()

Response shape

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

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

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

tsx
// 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>
  )
}
tsx
// 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.

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

TypeScript
const page = await lynkow.pages.getBySlug('homepage')

Both methods return the same Page object.

The Page response

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

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

tsx
// 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 null or empty and the page will include a warning in page._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.

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

TypeScript
// 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),
  }))
}

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:

TypeScript
const { data: legalPages } = await lynkow.pages.list({ tag: 'legal' })

Display them in your footer:

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

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

TypeScript
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

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

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

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

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

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

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

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

globals.siteConfig()

10 minutes

pages.list()

5 minutes

pages.getBySlug()

5 minutes

pages.getByPath()

5 minutes

pages.getJsonLd()

5 minutes

To manually clear the SDK's internal cache:

TypeScript
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.revalidate set when creating the client (see Installation guide).