Maximize your site's search visibility and understand your audience with Lynkow's SEO and analytics tools. This guide covers meta tags, Open Graph, JSON-LD structured data, sitemaps, robots.txt, llms.txt, privacy-compliant analytics, and GDPR consent management.


1. Meta tags

Use Next.js generateMetadata() to set <title> and <meta name="description"> from Lynkow content fields.

Every content item returned by the SDK includes SEO fields:

  • metaTitle --- optimized title (falls back to title if empty)

  • metaDescription --- page description for search results

  • keywords --- comma-separated keyword string

  • canonicalUrl --- optional canonical URL override

TypeScript
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'

const BASE_URL = 'https://example.com'

interface Props {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const content = await lynkow.contents.getBySlug(slug)

  return {
    title: content.metaTitle || content.title,
    description: content.metaDescription || undefined,
    keywords: content.keywords || undefined,
    alternates: {
      canonical: content.canonicalUrl || `${BASE_URL}/blog/${slug}`,
    },
  }
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const content = await lynkow.contents.getBySlug(slug)

  return (
    <article>
      <h1>{content.title}</h1>
      {/* Render content body */}
    </article>
  )
}

This generates:

HTML
<title>Your SEO Title</title>
<meta name="description" content="Your meta description for search results." />
<meta name="keywords" content="lynkow, headless cms, seo" />
<link rel="canonical" href="https://example.com/blog/your-post" />

2. Open Graph

Open Graph tags control how your pages appear when shared on social media. Lynkow provides ogImage and ogImageVariants alongside the standard meta fields.

TypeScript
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'

const BASE_URL = 'https://example.com'

interface Props {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const content = await lynkow.contents.getBySlug(slug)

  const images = []
  if (content.ogImage) {
    images.push({
      url: content.ogImage,
      width: 1200,
      height: 630,
      alt: content.metaTitle || content.title,
    })
  }

  // Include image variants if available (e.g., square format for Twitter)
  if (content.ogImageVariants) {
    for (const variant of content.ogImageVariants) {
      images.push(variant)
    }
  }

  return {
    title: content.metaTitle || content.title,
    description: content.metaDescription || undefined,
    openGraph: {
      type: 'article',
      title: content.metaTitle || content.title,
      description: content.metaDescription || undefined,
      url: `${BASE_URL}/blog/${slug}`,
      siteName: 'Your Site Name',
      images,
      publishedTime: content.publishedAt || undefined,
      modifiedTime: content.updatedAt || undefined,
    },
    twitter: {
      card: 'summary_large_image',
      title: content.metaTitle || content.title,
      description: content.metaDescription || undefined,
      images: content.ogImage ? [content.ogImage] : undefined,
    },
  }
}

This generates:

HTML
<meta property="og:type" content="article" />
<meta property="og:title" content="Your SEO Title" />
<meta property="og:description" content="Your meta description." />
<meta property="og:image" content="https://cdn.example.com/og-image.jpg" />
<meta property="og:url" content="https://example.com/blog/your-post" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://cdn.example.com/og-image.jpg" />

3. Canonical URLs

Canonical URLs prevent duplicate content issues when the same content is accessible at multiple URLs. Lynkow's canonicalUrl field lets editors set an explicit canonical, with your code providing a fallback.

TypeScript
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const content = await lynkow.contents.getBySlug(slug)

  // If the editor set a canonical URL in the CMS, use it.
  // Otherwise, construct the canonical from the current URL.
  const canonical = content.canonicalUrl || `${BASE_URL}/blog/${slug}`

  return {
    alternates: {
      canonical,
    },
  }
}

Canonical with localized content

When serving content in multiple languages, each locale version should have its own canonical pointing to itself, plus hreflang alternates pointing to the other versions:

TypeScript
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 {
    alternates: {
      canonical: content.canonicalUrl || `${BASE_URL}/${locale}/blog/${slug}`,
      languages,
    },
  }
}

4. JSON-LD structured data

JSON-LD helps search engines understand your content structure. Lynkow automatically generates Article and FAQPage schemas that you inject into the page.

Article JSON-LD

Every content item provides structuredData.article.jsonLd containing a complete Article schema:

TypeScript
// app/blog/[slug]/page.tsx
import { lynkow } from '@/lib/lynkow'

interface Props {
  params: Promise<{ slug: string }>
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const content = await lynkow.contents.getBySlug(slug)

  return (
    <article>
      <h1>{content.title}</h1>

      {/* Render content body */}

      {/* Article JSON-LD */}
      {content.structuredData?.article?.jsonLd && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(content.structuredData.article.jsonLd),
          }}
        />
      )}

      {/* FAQPage JSON-LD (only present if content has FAQ blocks) */}
      {content.structuredData?.faq?.jsonLd && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify(content.structuredData.faq.jsonLd),
          }}
        />
      )}
    </article>
  )
}

The Article schema typically looks like:

JSON
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "Your Article Title",
  "description": "Your meta description",
  "image": "https://cdn.example.com/og-image.jpg",
  "datePublished": "2026-01-15T10:00:00Z",
  "dateModified": "2026-03-20T14:30:00Z",
  "author": {
    "@type": "Person",
    "name": "Author Name"
  }
}

FAQPage JSON-LD

The structuredData.faq.jsonLd field is null when the content has no FAQ blocks. When present, it contains a valid FAQPage schema:

JSON
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "What is Lynkow?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Lynkow is a headless CMS..."
      }
    }
  ]
}

Always check for null before rendering the FAQ script tag to avoid injecting empty structured data.


5. XML Sitemap

Serve a dynamic XML sitemap using the seo.sitemap() method. Create a route handler that returns the XML response.

Basic sitemap

TypeScript
// app/sitemap.xml/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const xml = await lynkow.seo.sitemap()

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  })
}

Partitioned sitemap for large sites

For sites with thousands of pages, use partitioned sitemaps to stay within the 50,000 URL / 50 MB limit per sitemap file:

TypeScript
// app/sitemap.xml/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  // Returns a sitemap index pointing to individual parts
  const xml = await lynkow.seo.sitemap()

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  })
}
TypeScript
// app/sitemap-[part].xml/route.ts
import { lynkow } from '@/lib/lynkow'
import { NextRequest } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ part: string }> }
) {
  const { part } = await params
  const xml = await lynkow.seo.sitemapPart(part)

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  })
}

6. Robots.txt

Serve a dynamic robots.txt that reflects your site's SEO settings configured in the Lynkow dashboard.

TypeScript
// app/robots.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const robotsTxt = await lynkow.seo.robots()

  return new Response(robotsTxt, {
    headers: {
      'Content-Type': 'text/plain',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}

The response typically includes:

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml

7. llms.txt

The llms.txt standard helps large language models understand your site's content. Lynkow generates both a summary version and a full markdown version.

Summary llms.txt

TypeScript
// app/llms.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const llmsTxt = await lynkow.seo.llmsTxt({ locale: 'en' })

  return new Response(llmsTxt, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}

Full llms.txt (complete markdown)

TypeScript
// app/llms-full.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const fullTxt = await lynkow.seo.llmsFullTxt({ locale: 'en' })

  return new Response(fullTxt, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}

Single content as markdown

You can also expose individual content items as markdown, useful for AI-friendly endpoints:

TypeScript
// app/md/[...path]/route.ts
import { lynkow } from '@/lib/lynkow'
import { NextRequest } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  const contentPath = `/${path.join('/')}`

  const markdown = await lynkow.seo.getMarkdown(contentPath)

  return new Response(markdown, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  })
}

Lynkow's analytics tracker runs in the browser. It auto-tracks the first pageview on initialization, and you can manually track SPA navigations.

GDPR / opt-in sites: If your site requires user consent before tracking (required in the EU), do NOT use analytics.init() alone — it starts tracking immediately on page load. Use the consent-aware pattern in Section 10: GDPR Consent instead, which only initializes analytics after the user has accepted.

Initialize the tracker in your root layout

TypeScript
// components/analytics.tsx
'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'

export function Analytics() {
  const pathname = usePathname()

  // Initialize tracker on mount (auto-tracks first pageview)
  useEffect(() => {
    lynkow.analytics.init()
  }, [])

  // Track SPA navigations
  useEffect(() => {
    // Skip the initial pageview (already tracked by init)
    if (pathname) {
      lynkow.analytics.trackPageview({ path: pathname })
    }
  }, [pathname])

  return null
}

Add it to your root layout:

TypeScript
// app/layout.tsx
import { Analytics } from '@/components/analytics'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

How init() works

lynkow.analytics.init() is idempotent --- calling it multiple times has no effect after the first call. It:

  1. Loads the tracker.js script

  2. Automatically records a pageview event for the current page

  3. Begins collecting Core Web Vitals, scroll depth, and time-on-page data

Enabling and disabling tracking

You can toggle tracking at runtime, for example based on user consent:

TypeScript
// Disable all tracking
lynkow.analytics.disable()

// Re-enable tracking
lynkow.analytics.enable()

9. Custom events

Track user interactions beyond pageviews to understand engagement patterns.

Track button clicks

TypeScript
// components/cta-button.tsx
'use client'

import { lynkow } from '@/lib/lynkow'

interface CtaButtonProps {
  label: string
  href: string
  campaign?: string
}

export function CtaButton({ label, href, campaign }: CtaButtonProps) {
  const handleClick = () => {
    lynkow.analytics.trackEvent({
      type: 'click',
      label,
      href,
      campaign: campaign || 'default',
    })
  }

  return (
    <a href={href} onClick={handleClick} className="btn btn-primary">
      {label}
    </a>
  )
}

Track form interactions

TypeScript
// components/contact-form.tsx
'use client'

import { useState } from 'react'
import { lynkow } from '@/lib/lynkow'

export function ContactForm() {
  const [started, setStarted] = useState(false)

  const handleFocus = () => {
    if (!started) {
      setStarted(true)
      lynkow.analytics.trackEvent({
        type: 'form_start',
        formName: 'contact',
      })
    }
  }

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    lynkow.analytics.trackEvent({
      type: 'form_submit',
      formName: 'contact',
    })

    // Submit the form data...
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        type="email"
        placeholder="Email"
        onFocus={handleFocus}
      />
      <textarea
        name="message"
        placeholder="Message"
        onFocus={handleFocus}
      />
      <button type="submit">Send</button>
    </form>
  )
}

Track content engagement

TypeScript
// components/content-actions.tsx
'use client'

import { lynkow } from '@/lib/lynkow'

interface ContentActionsProps {
  contentId: number
  contentTitle: string
}

export function ContentActions({ contentId, contentTitle }: ContentActionsProps) {
  const handleShare = (platform: string) => {
    lynkow.analytics.trackEvent({
      type: 'click',
      action: 'share',
      platform,
      contentId,
      contentTitle,
    })
  }

  const handleBookmark = () => {
    lynkow.analytics.trackEvent({
      type: 'click',
      action: 'bookmark',
      contentId,
      contentTitle,
    })
  }

  return (
    <div className="flex gap-2">
      <button onClick={() => handleShare('twitter')}>Share on X</button>
      <button onClick={() => handleShare('linkedin')}>Share on LinkedIn</button>
      <button onClick={handleBookmark}>Bookmark</button>
    </div>
  )
}

Lynkow provides a built-in consent management system that handles cookie banners, preference storage, and conditional script loading.

TypeScript
// components/cookie-consent.tsx
'use client'

import { useEffect } from 'react'
import { lynkow } from '@/lib/lynkow'

export function CookieConsent() {
  useEffect(() => {
    // Shows the GDPR cookie banner if the user hasn't consented yet.
    // If consent was already given, this is a no-op.
    lynkow.consent.show()
  }, [])

  return null
}

Add it to your layout:

TypeScript
// app/layout.tsx
import { Analytics } from '@/components/analytics'
import { CookieConsent } from '@/components/cookie-consent'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
        <CookieConsent />
      </body>
    </html>
  )
}

If you want full control over the consent UI, build your own and use the SDK methods:

TypeScript
// components/custom-consent-banner.tsx
'use client'

import { useState, useEffect } from 'react'
import { lynkow } from '@/lib/lynkow'

export function CustomConsentBanner() {
  const [visible, setVisible] = useState(false)
  const [showDetails, setShowDetails] = useState(false)

  useEffect(() => {
    // Only show if user hasn't consented yet
    if (!lynkow.consent.hasConsented()) {
      setVisible(true)
    }
  }, [])

  const handleAcceptAll = () => {
    lynkow.consent.acceptAll()
    setVisible(false)
  }

  const handleRejectAll = () => {
    lynkow.consent.rejectAll()
    setVisible(false)
  }

  const handleSavePreferences = (preferences: {
    analytics: boolean
    marketing: boolean
  }) => {
    lynkow.consent.setCategories({
      analytics: preferences.analytics,
      marketing: preferences.marketing,
    })
    setVisible(false)
  }

  if (!visible) return null

  return (
    <div className="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-6 z-50">
      <div className="max-w-4xl mx-auto">
        <h3 className="text-lg font-semibold">Cookie Preferences</h3>
        <p className="mt-2 text-gray-600">
          We use cookies to improve your experience. You can customize your
          preferences below.
        </p>

        {showDetails ? (
          <ConsentDetails
            onSave={handleSavePreferences}
            onCancel={() => setShowDetails(false)}
          />
        ) : (
          <div className="flex gap-3 mt-4">
            <button
              onClick={handleAcceptAll}
              className="px-4 py-2 bg-black text-white rounded"
            >
              Accept All
            </button>
            <button
              onClick={handleRejectAll}
              className="px-4 py-2 border rounded"
            >
              Reject Optional
            </button>
            <button
              onClick={() => setShowDetails(true)}
              className="px-4 py-2 text-gray-600 underline"
            >
              Customize
            </button>
          </div>
        )}
      </div>
    </div>
  )
}

function ConsentDetails({
  onSave,
  onCancel,
}: {
  onSave: (prefs: { analytics: boolean; marketing: boolean }) => void
  onCancel: () => void
}) {
  const [analytics, setAnalytics] = useState(false)
  const [marketing, setMarketing] = useState(false)

  return (
    <div className="mt-4 space-y-3">
      <label className="flex items-center gap-2">
        <input type="checkbox" checked disabled />
        <span>Necessary (always enabled)</span>
      </label>
      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={analytics}
          onChange={(e) => setAnalytics(e.target.checked)}
        />
        <span>Analytics</span>
      </label>
      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={marketing}
          onChange={(e) => setMarketing(e.target.checked)}
        />
        <span>Marketing</span>
      </label>
      <div className="flex gap-3 mt-4">
        <button
          onClick={() => onSave({ analytics, marketing })}
          className="px-4 py-2 bg-black text-white rounded"
        >
          Save Preferences
        </button>
        <button onClick={onCancel} className="px-4 py-2 border rounded">
          Cancel
        </button>
      </div>
    </div>
  )
}

GDPR requires that users can modify their cookie preferences at any time after their initial choice — not just when the banner first appears. Add a link in your footer (or privacy page) that re-opens the consent banner:

TypeScript
// components/manage-cookies-button.tsx
'use client'

import { lynkow } from '@/lib/lynkow'

export function ManageCookiesButton() {
  return (
    <button
      onClick={() => lynkow.consent.show()}
      className="text-sm text-gray-500 underline"
    >
      Manage cookie preferences
    </button>
  )
}
TypeScript
// In your footer component
import { ManageCookiesButton } from '@/components/manage-cookies-button'

export function Footer() {
  return (
    <footer className="border-t py-8 text-center">
      <p>© 2026 Your Company</p>
      <ManageCookiesButton />
    </footer>
  )
}

Listen for consent changes and enable or disable analytics accordingly:

TypeScript
// components/analytics-with-consent.tsx
'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'

export function AnalyticsWithConsent() {
  const pathname = usePathname()

  useEffect(() => {
    // Check initial consent state
    const categories = lynkow.consent.getCategories()

    if (categories.analytics) {
      lynkow.analytics.init()
    } else {
      lynkow.analytics.disable()
    }

    // Listen for consent changes
    lynkow.on('consent-changed', (prefs) => {
      if (prefs.analytics) {
        lynkow.analytics.enable()
        lynkow.analytics.init()
      } else {
        lynkow.analytics.disable()
      }
    })
  }, [])

  // Track SPA navigations only if analytics is enabled
  useEffect(() => {
    const categories = lynkow.consent.getCategories()
    if (pathname && categories.analytics) {
      lynkow.analytics.trackPageview({ path: pathname })
    }
  }, [pathname])

  return null
}
TypeScript
// Check if user has made any consent choice
const hasConsented = lynkow.consent.hasConsented() // true or false

// Read current consent categories
const categories = lynkow.consent.getCategories()
// { necessary: true, analytics: false, marketing: false }

Third-party script injection

When consent is granted for a category, the SDK automatically injects third-party scripts associated with that category (as configured in the Lynkow dashboard). No additional code is needed --- the SDK handles script loading and removal based on the active consent categories.


11. Complete SEO head component

A reusable component that handles all SEO concerns for any content page: meta tags, Open Graph, canonical, hreflang, and JSON-LD.

TypeScript
// lib/seo.ts
import { Metadata } from 'next'

const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'

interface SeoContent {
  title: string
  metaTitle?: string | null
  metaDescription?: string | null
  keywords?: string | null
  canonicalUrl?: string | null
  ogImage?: string | null
  ogImageVariants?: Array<{ url: string; width?: number; height?: number }> | null
  publishedAt?: string | null
  updatedAt?: string | null
  structuredData?: {
    article?: { jsonLd: Record<string, unknown> } | null
    faq?: { jsonLd: Record<string, unknown> } | null
    alternates?: Array<{ locale: string; url: string }> | null
  } | null
}

interface GenerateMetadataOptions {
  content: SeoContent
  path: string
  locale?: string
  type?: 'article' | 'website'
  siteName?: string
}

export function generateContentMetadata({
  content,
  path,
  locale,
  type = 'article',
  siteName = 'Your Site',
}: GenerateMetadataOptions): Metadata {
  const title = content.metaTitle || content.title
  const description = content.metaDescription || undefined
  const canonical = content.canonicalUrl || `${BASE_URL}${path}`

  // Open Graph images
  const images = []
  if (content.ogImage) {
    images.push({
      url: content.ogImage,
      width: 1200,
      height: 630,
      alt: title,
    })
  }
  if (content.ogImageVariants) {
    images.push(...content.ogImageVariants)
  }

  // Hreflang alternates
  const alternates = content.structuredData?.alternates || []
  const languages: Record<string, string> = {}
  for (const alt of alternates) {
    languages[alt.locale] = alt.url
  }

  return {
    title,
    description,
    keywords: content.keywords || undefined,
    alternates: {
      canonical,
      languages: Object.keys(languages).length > 0 ? languages : undefined,
    },
    openGraph: {
      type,
      title,
      description,
      url: canonical,
      siteName,
      images: images.length > 0 ? images : undefined,
      locale: locale || undefined,
      publishedTime: content.publishedAt || undefined,
      modifiedTime: content.updatedAt || undefined,
      alternateLocale: alternates
        .filter((alt) => alt.locale !== locale)
        .map((alt) => alt.locale),
    },
    twitter: {
      card: 'summary_large_image',
      title,
      description,
      images: content.ogImage ? [content.ogImage] : undefined,
    },
  }
}

JSON-LD injection component

TypeScript
// components/json-ld.tsx
interface JsonLdProps {
  data: Record<string, unknown> | null | undefined
}

export function JsonLd({ data }: JsonLdProps) {
  if (!data) return null

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

Using the complete SEO setup

TypeScript
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { generateContentMetadata } from '@/lib/seo'
import { JsonLd } from '@/components/json-ld'

interface Props {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  try {
    const content = await lynkow.contents.getBySlug(slug)
    return generateContentMetadata({
      content,
      path: `/blog/${slug}`,
      type: 'article',
      siteName: 'My Blog',
    })
  } catch {
    return { title: 'Not Found' }
  }
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params

  let content
  try {
    content = await lynkow.contents.getBySlug(slug)
  } catch {
    notFound()
  }

  return (
    <article className="max-w-3xl mx-auto p-8">
      <h1 className="text-4xl font-bold">{content.title}</h1>

      {content.metaDescription && (
        <p className="text-lg text-gray-600 mt-2">{content.metaDescription}</p>
      )}

      <div className="prose mt-8">
        {/* Render content body */}
      </div>

      {/* Structured data */}
      <JsonLd data={content.structuredData?.article?.jsonLd} />
      <JsonLd data={content.structuredData?.faq?.jsonLd} />
    </article>
  )
}
TypeScript
// app/layout.tsx
import { ReactNode } from 'react'
import { AnalyticsWithConsent } from '@/components/analytics-with-consent'
import { CustomConsentBanner } from '@/components/custom-consent-banner'

export const metadata = {
  metadataBase: new URL('https://example.com'),
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <AnalyticsWithConsent />
        <CustomConsentBanner />
      </body>
    </html>
  )
}

Summary

Feature

API / Method

Route

Meta tags

content.metaTitle, metaDescription, keywords

generateMetadata()

Open Graph

content.ogImage, ogImageVariants

generateMetadata()

Canonical URL

content.canonicalUrl

generateMetadata()

Article JSON-LD

content.structuredData.article.jsonLd

<script type="application/ld+json">

FAQ JSON-LD

content.structuredData.faq.jsonLd

<script type="application/ld+json">

XML Sitemap

lynkow.seo.sitemap()

app/sitemap.xml/route.ts

Sitemap parts

lynkow.seo.sitemapPart(part)

app/sitemap-[part].xml/route.ts

Robots.txt

lynkow.seo.robots()

app/robots.txt/route.ts

llms.txt

lynkow.seo.llmsTxt({ locale })

app/llms.txt/route.ts

llms-full.txt

lynkow.seo.llmsFullTxt({ locale })

app/llms-full.txt/route.ts

Content markdown

lynkow.seo.getMarkdown(path)

app/md/[...path]/route.ts

Analytics init

lynkow.analytics.init()

Client component

SPA pageview

lynkow.analytics.trackPageview({ path })

Client component

Custom events

lynkow.analytics.trackEvent({ type, ... })

Client component

Enable/disable

lynkow.analytics.enable() / disable()

Client component

Consent banner

lynkow.consent.show()

Client component

Accept all

lynkow.consent.acceptAll()

Client component

Reject all

lynkow.consent.rejectAll()

Client component

Custom prefs

lynkow.consent.setCategories({...})

Client component

Check consent

lynkow.consent.hasConsented()

Client component

Read categories

lynkow.consent.getCategories()

Client component

Consent listener

lynkow.on('consent-changed', cb)

Client component