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 totitleif empty)metaDescription--- page description for search resultskeywords--- comma-separated keyword stringcanonicalUrl--- optional canonical URL override
// 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:
<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.
// 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:
<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.
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:
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:
// 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:
{
"@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:
{
"@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
// 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:
// 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',
},
})
}// 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.
// 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.xml7. 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
// 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)
// 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:
// 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',
},
})
}8. Analytics setup (no consent required)
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
// 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:
// 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:
Loads the
tracker.jsscriptAutomatically records a
pageviewevent for the current pageBegins 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:
// 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
// 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
// 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
// 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>
)
}10. GDPR consent
Lynkow provides a built-in consent management system that handles cookie banners, preference storage, and conditional script loading.
Show the consent banner
// 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:
// 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>
)
}Custom consent UI
If you want full control over the consent UI, build your own and use the SDK methods:
// 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>
)
}Re-open consent preferences (required by GDPR)
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:
// 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>
)
}// 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>
)
}Conditional tracking based on consent
Listen for consent changes and enable or disable analytics accordingly:
// 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
}Reading consent state
// 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.
// 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
// 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
// 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>
)
}Full layout with analytics and consent
// 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 |
|
|
Open Graph |
|
|
Canonical URL |
|
|
Article JSON-LD |
|
|
FAQ JSON-LD |
|
|
XML Sitemap |
|
|
Sitemap parts |
|
|
Robots.txt |
|
|
llms.txt |
|
|
llms-full.txt |
|
|
Content markdown |
|
|
Analytics init |
| Client component |
SPA pageview |
| Client component |
Custom events |
| Client component |
Enable/disable |
| Client component |
Consent banner |
| Client component |
Accept all |
| Client component |
Reject all |
| Client component |
Custom prefs |
| Client component |
Check consent |
| Client component |
Read categories |
| Client component |
Consent listener |
| Client component |