> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# Build a Blog with Next.js

**Publié le** : 2026-08-14
**Catégorie** : Getting Started

This guide walks you through building a complete, production-ready blog using Next.js 15 (App Router) and the Lynkow SDK. You will implement article listings with pagination, single article pages with SEO metadata, category pages, tag filtering, responsive images, and static generation.

> **Reference** - This guide is a tutorial. For exhaustive, always-current signatures and field lists, see the auto-generated reference:
> 
> - SDK: [`ContentsService`](/docs/sdk/services/sdk-contents-service)
> - API: [Storefront: Content](/docs/storefront-api/storefront-content)

## Project Structure

By the end of this guide, your project will have this structure:

```
my-blog/
├── app/
│   ├── blog/
│   │   ├── page.tsx                    # Blog listing with pagination
│   │   ├── [slug]/
│   │   │   └── page.tsx                # Single article
│   │   └── category/
│   │       └── [slug]/
│   │           └── page.tsx            # Category page
│   └── layout.tsx                      # Root layout
├── components/
│   ├── article-card.tsx                # Reusable article card
│   ├── pagination.tsx                  # Pagination controls
│   └── responsive-image.tsx            # Image with srcset
├── lib/
│   └── lynkow.ts                       # Shared SDK client
├── .env.local
├── next.config.ts
└── package.json
```

## Setup

If you are starting from scratch:

```bash
npx create-next-app@latest my-blog --typescript --app --tailwind
cd my-blog
npm install lynkow
```

Add your Site ID to `.env.local`:

```env
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-id-here
```

## Shared Client

Create `lib/lynkow.ts`. This is the single instance imported everywhere. The `revalidate: 60` setting enables Incremental Static Regeneration (ISR), meaning pages are served from cache and refreshed in the background every 60 seconds.

```typescript
import { createClient } from 'lynkow'

export const lynkow = createClient({
  siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
  fetchOptions: {
    next: { revalidate: 60 },
  },
})
```

## Reusable Components

### Article Card

Create `components/article-card.tsx`:

```tsx
import Link from 'next/link'
import { ResponsiveImage } from './responsive-image'

interface ArticleCardProps {
  title: string
  slug: string
  excerpt: string | null
  featuredImage: string | null
  featuredImageVariants: Record<string, string> | null
  publishedAt: string
}

export function ArticleCard({
  title,
  slug,
  excerpt,
  featuredImage,
  featuredImageVariants,
  publishedAt,
}: ArticleCardProps) {
  const formattedDate = new Date(publishedAt).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  })

  return (
    <article className="group border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
      {featuredImage && (
        <Link href={`/blog/${slug}`}>
          <ResponsiveImage
            src={featuredImage}
            variants={featuredImageVariants}
            alt={title}
            className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
          />
        </Link>
      )}
      <div className="p-6">
        <time dateTime={publishedAt} className="text-sm text-gray-500">
          {formattedDate}
        </time>
        <h2 className="mt-2 text-xl font-semibold">
          <Link href={`/blog/${slug}`} className="hover:text-blue-600 transition-colors">
            {title}
          </Link>
        </h2>
        {excerpt && (
          <p className="mt-2 text-gray-600 line-clamp-3">{excerpt}</p>
        )}
      </div>
    </article>
  )
}
```

### Responsive Image

Create `components/responsive-image.tsx`. This component uses the pre-computed image variants returned by the API, which include optimized sizes for thumbnails, cards, hero sections, and Open Graph images.

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

interface ResponsiveImageProps {
  src: string
  variants: Record<string, string> | null
  alt: string
  className?: string
  sizes?: string
  priority?: boolean
}

export function ResponsiveImage({
  src,
  variants,
  alt,
  className,
  sizes = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw',
  priority = false,
}: ResponsiveImageProps) {
  // Generate srcset from the SDK for fine-grained control
  const srcSet = lynkow.media.srcset(src, {
    widths: [320, 640, 960, 1280, 1920],
  })

  // Use the card variant as the default src, falling back to the original
  const defaultSrc = variants?.card || src

  return (
    <img
      src={defaultSrc}
      srcSet={srcSet}
      sizes={sizes}
      alt={alt}
      className={className}
      loading={priority ? 'eager' : 'lazy'}
      decoding="async"
    />
  )
}
```

The `lynkow.media.srcset()` method generates a standards-compliant `srcset` attribute string from a source URL. You pass in the widths you need, and it returns transformed URLs for each width.

For one-off transformations, use `lynkow.media.transform()`:

```typescript
const thumbnailUrl = lynkow.media.transform(imageUrl, {
  width: 300,
  height: 200,
  fit: 'cover',
})
```

### Pagination

Create `components/pagination.tsx`:

```tsx
import Link from 'next/link'

interface PaginationProps {
  currentPage: number
  lastPage: number
  basePath: string
}

export function Pagination({ currentPage, lastPage, basePath }: PaginationProps) {
  if (lastPage <= 1) return null

  const pages: number[] = []

  // Show a window of pages around the current page
  const start = Math.max(1, currentPage - 2)
  const end = Math.min(lastPage, currentPage + 2)

  for (let i = start; i <= end; i++) {
    pages.push(i)
  }

  function pageUrl(page: number): string {
    if (page === 1) return basePath
    return `${basePath}?page=${page}`
  }

  return (
    <nav aria-label="Pagination" className="flex items-center justify-center gap-2 mt-12">
      {currentPage > 1 && (
        <Link
          href={pageUrl(currentPage - 1)}
          className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
        >
          Previous
        </Link>
      )}

      {start > 1 && (
        <>
          <Link
            href={pageUrl(1)}
            className="px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
          >
            1
          </Link>
          {start > 2 && <span className="px-2 text-gray-400">...</span>}
        </>
      )}

      {pages.map((page) => (
        <Link
          key={page}
          href={pageUrl(page)}
          className={`px-3 py-2 text-sm border rounded-md ${
            page === currentPage
              ? 'bg-blue-600 text-white border-blue-600'
              : 'border-gray-300 hover:bg-gray-50'
          }`}
        >
          {page}
        </Link>
      ))}

      {end < lastPage && (
        <>
          {end < lastPage - 1 && <span className="px-2 text-gray-400">...</span>}
          <Link
            href={pageUrl(lastPage)}
            className="px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
          >
            {lastPage}
          </Link>
        </>
      )}

      {currentPage < lastPage && (
        <Link
          href={pageUrl(currentPage + 1)}
          className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50"
        >
          Next
        </Link>
      )}
    </nav>
  )
}
```

## Blog Listing Page

Create `app/blog/page.tsx`. This page fetches paginated articles and renders them as cards.

```tsx
import { Metadata } from 'next'
import { lynkow } from '@/lib/lynkow'
import { ArticleCard } from '@/components/article-card'
import { Pagination } from '@/components/pagination'

export const metadata: Metadata = {
  title: 'Blog',
  description: 'Read our latest articles and insights.',
}

type SearchParams = Promise<{ page?: string; tag?: string }>

export default async function BlogPage({
  searchParams,
}: {
  searchParams: SearchParams
}) {
  const { page: pageParam, tag } = await searchParams
  const page = Number(pageParam) || 1

  const { data: articles, meta } = await lynkow.contents.list({
    page,
    limit: 10,
    sort: 'published_at',
    order: 'desc',
    ...(tag && { tag }),
  })

  return (
    <main className="max-w-6xl mx-auto px-4 py-12">
      <header className="mb-12">
        <h1 className="text-4xl font-bold">Blog</h1>
        {tag && (
          <p className="mt-2 text-gray-600">
            Filtered by tag: <span className="font-medium">{tag}</span>
          </p>
        )}
        <p className="mt-2 text-gray-500">{meta.total} articles</p>
      </header>

      {articles.length === 0 ? (
        <p className="text-gray-500">No articles found.</p>
      ) : (
        <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
          {articles.map((article) => (
            <ArticleCard
              key={article.id}
              title={article.title}
              slug={article.slug}
              excerpt={article.excerpt}
              featuredImage={article.featuredImage}
              featuredImageVariants={article.featuredImageVariants}
              publishedAt={article.publishedAt}
            />
          ))}
        </div>
      )}

      <Pagination
        currentPage={meta.currentPage}
        lastPage={meta.lastPage}
        basePath="/blog"
      />
    </main>
  )
}
```

### Tag Filtering

The listing page above already supports tag filtering via the `tag` query parameter. Link to filtered views like this:

```tsx
<a href="/blog?tag=featured">Featured articles</a>
<a href="/blog?tag=tutorials">Tutorials</a>
```

You can also fetch all available tags to build a tag cloud or filter menu:

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

const { data: tags } = await lynkow.tags.list()

// Render tag links
tags.map((tag) => (
  <a key={tag.id} href={`/blog?tag=${tag.slug}`}>
    {tag.name}
  </a>
))
```

## Single Article Page

Create `app/blog/[slug]/page.tsx`. This is the most important page -- it renders the full article with SEO metadata and JSON-LD structured data.

```tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { lynkow } from '@/lib/lynkow'
import { ResponsiveImage } from '@/components/responsive-image'

type Params = Promise<{ slug: string }>

// ---------------------------------------------------------------------------
// Static generation: pre-render all published article paths at build time
// ---------------------------------------------------------------------------
export async function generateStaticParams() {
  const { paths } = await lynkow.paths.list()

  return paths
    .filter((p) => p.type === 'content')
    .map((p) => {
      // The path comes as "/category/slug" or "/slug" depending on blog URL mode.
      // Extract the last segment as the slug.
      const segments = p.path.split('/').filter(Boolean)
      return { slug: segments[segments.length - 1] }
    })
}

// ---------------------------------------------------------------------------
// SEO metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
  const { slug } = await params

  try {
    const article = await lynkow.contents.getBySlug(slug)

    return {
      title: article.metaTitle || article.title,
      description: article.metaDescription || article.excerpt || undefined,
      openGraph: {
        title: article.metaTitle || article.title,
        description: article.metaDescription || article.excerpt || undefined,
        type: 'article',
        publishedTime: article.publishedAt,
        authors: article.author ? [article.author.fullName] : undefined,
        images: article.ogImage
          ? [{ url: article.ogImage }]
          : article.featuredImage
            ? [{ url: article.featuredImage }]
            : undefined,
      },
      twitter: {
        card: 'summary_large_image',
        title: article.metaTitle || article.title,
        description: article.metaDescription || article.excerpt || undefined,
      },
    }
  } catch {
    return { title: 'Article Not Found' }
  }
}

// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default async function ArticlePage({ params }: { params: Params }) {
  const { slug } = await params

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

  const formattedDate = new Date(article.publishedAt).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  })

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

      <main className="max-w-3xl mx-auto px-4 py-12">
        <article>
          {/* Categories */}
          {article.categories.length > 0 && (
            <div className="flex gap-2 mb-4">
              {article.categories.map((category) => (
                <Link
                  key={category.id}
                  href={`/blog/category/${category.slug}`}
                  className="text-sm font-medium text-blue-600 hover:text-blue-800"
                >
                  {category.name}
                </Link>
              ))}
            </div>
          )}

          {/* Title */}
          <h1 className="text-4xl font-bold leading-tight">{article.title}</h1>

          {/* Author and date */}
          <div className="flex items-center gap-4 mt-6 text-gray-600">
            {article.author && (
              <div className="flex items-center gap-3">
                {article.author.avatarUrl && (
                  <img
                    src={article.author.avatarUrl}
                    alt={article.author.fullName}
                    className="w-10 h-10 rounded-full"
                  />
                )}
                <span className="font-medium">{article.author.fullName}</span>
              </div>
            )}
            <time dateTime={article.publishedAt}>{formattedDate}</time>
          </div>

          {/* Featured image */}
          {article.featuredImage && (
            <div className="mt-8">
              <ResponsiveImage
                src={article.featuredImage}
                variants={article.featuredImageVariants}
                alt={article.title}
                className="w-full rounded-lg"
                sizes="(max-width: 768px) 100vw, 768px"
                priority
              />
            </div>
          )}

          {/* Article body */}
          <div
            className="mt-10 prose prose-lg max-w-none"
            dangerouslySetInnerHTML={{ __html: article.body }}
          />

          {/* Tags */}
          {article.tags.length > 0 && (
            <div className="mt-12 pt-8 border-t border-gray-200">
              <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">Tags</h2>
              <div className="flex flex-wrap gap-2 mt-3">
                {article.tags.map((tag) => (
                  <Link
                    key={tag.id}
                    href={`/blog?tag=${tag.slug}`}
                    className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
                  >
                    {tag.name}
                  </Link>
                ))}
              </div>
            </div>
          )}
        </article>
      </main>
    </>
  )
}
```

### Key details about the article page

**Static generation with ****`generateStaticParams`****:** At build time, Next.js calls `paths.list()` to discover all published content paths and pre-renders a page for each one. This means articles are served as static HTML with zero server processing time.

**SEO metadata with ****`generateMetadata`****:** Each article page generates its own `<title>`, `<meta name="description">`, and Open Graph tags from the content's SEO fields. If the author filled in `metaTitle` and `metaDescription` in the Lynkow dashboard, those are used; otherwise the title and excerpt serve as fallbacks.

**JSON-LD injection:** If the content has `structuredData` (configured in Lynkow's SEO settings), it is injected as a `<script type="application/ld+json">` tag. This provides search engines with structured information about the article (author, date, publisher, etc.).

**HTML body rendering:** The `body` field returned by `getBySlug` is already rendered HTML. It is injected with `dangerouslySetInnerHTML`. Pair it with the `@tailwindcss/typography` plugin's `prose` class for proper styling of headings, paragraphs, lists, code blocks, and other rich content elements.

## Category Page

Create `app/blog/category/[slug]/page.tsx`. This page shows a category and its published articles.

```tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { ArticleCard } from '@/components/article-card'
import { Pagination } from '@/components/pagination'

type Params = Promise<{ slug: string }>
type SearchParams = Promise<{ page?: string }>

// ---------------------------------------------------------------------------
// Static generation: pre-render all category pages
// ---------------------------------------------------------------------------
export async function generateStaticParams() {
  const { categories } = await lynkow.categories.list()

  return categories.map((category) => ({
    slug: category.slug,
  }))
}

// ---------------------------------------------------------------------------
// SEO metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
  const { slug } = await params

  try {
    const { category } = await lynkow.categories.getBySlug(slug, { page: 1, limit: 1 })

    return {
      title: category.name,
      description: category.description || `Articles in ${category.name}`,
      openGraph: {
        title: category.name,
        description: category.description || `Articles in ${category.name}`,
        ...(category.image && { images: [{ url: category.image }] }),
      },
    }
  } catch {
    return { title: 'Category Not Found' }
  }
}

// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default async function CategoryPage({
  params,
  searchParams,
}: {
  params: Params
  searchParams: SearchParams
}) {
  const { slug } = await params
  const { page: pageParam } = await searchParams
  const page = Number(pageParam) || 1

  let result
  try {
    result = await lynkow.categories.getBySlug(slug, { page, limit: 10 })
  } catch {
    notFound()
  }

  const { category, contents } = result

  return (
    <main className="max-w-6xl mx-auto px-4 py-12">
      <header className="mb-12">
        <h1 className="text-4xl font-bold">{category.name}</h1>
        {category.description && (
          <p className="mt-4 text-lg text-gray-600">{category.description}</p>
        )}
        <p className="mt-2 text-gray-500">{contents.meta.total} articles</p>
      </header>

      {contents.data.length === 0 ? (
        <p className="text-gray-500">No articles in this category yet.</p>
      ) : (
        <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
          {contents.data.map((article) => (
            <ArticleCard
              key={article.id}
              title={article.title}
              slug={article.slug}
              excerpt={article.excerpt}
              featuredImage={article.featuredImage}
              featuredImageVariants={article.featuredImageVariants}
              publishedAt={article.publishedAt}
            />
          ))}
        </div>
      )}

      <Pagination
        currentPage={contents.meta.currentPage}
        lastPage={contents.meta.lastPage}
        basePath={`/blog/category/${slug}`}
      />
    </main>
  )
}
```

### Building a Category Navigation

To display a list of all categories (for a sidebar or navigation menu), use `categories.list()` or `categories.tree()`:

```tsx
import { lynkow } from '@/lib/lynkow'
import Link from 'next/link'

export async function CategoryNav() {
  const { categories } = await lynkow.categories.list()

  return (
    <nav>
      <h2 className="text-lg font-semibold mb-4">Categories</h2>
      <ul className="space-y-2">
        {categories.map((category) => (
          <li key={category.id}>
            <Link
              href={`/blog/category/${category.slug}`}
              className="text-gray-700 hover:text-blue-600 transition-colors"
            >
              {category.name}
              <span className="ml-2 text-sm text-gray-400">({category.contentCount})</span>
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  )
}
```

For sites with nested categories, use `categories.tree()` to get the hierarchical structure:

```tsx
import { lynkow } from '@/lib/lynkow'
import Link from 'next/link'

interface CategoryNode {
  id: string
  name: string
  slug: string
  path: string
  contentCount: number
  children: CategoryNode[]
}

function CategoryTreeItem({ node }: { node: CategoryNode }) {
  return (
    <li>
      <Link
        href={`/blog/category/${node.slug}`}
        className="text-gray-700 hover:text-blue-600"
      >
        {node.name} ({node.contentCount})
      </Link>
      {node.children.length > 0 && (
        <ul className="ml-4 mt-1 space-y-1">
          {node.children.map((child) => (
            <CategoryTreeItem key={child.id} node={child} />
          ))}
        </ul>
      )}
    </li>
  )
}

export async function CategoryTree() {
  const { tree } = await lynkow.categories.tree()

  return (
    <nav>
      <h2 className="text-lg font-semibold mb-4">Categories</h2>
      <ul className="space-y-2">
        {tree.map((node) => (
          <CategoryTreeItem key={node.id} node={node} />
        ))}
      </ul>
    </nav>
  )
}
```

## Responsive Images

The Lynkow SDK provides two methods for working with images.

### `lynkow.media.srcset(url, { widths })`

Generates a complete `srcset` attribute string. Use this in `<img>` tags for responsive images:

```tsx
const srcSet = lynkow.media.srcset(article.featuredImage, {
  widths: [320, 640, 960, 1280, 1920],
})

// Result: "https://cdn.../image.jpg?w=320 320w, https://cdn.../image.jpg?w=640 640w, ..."
```

```tsx
<img
  src={article.featuredImageVariants?.hero || article.featuredImage}
  srcSet={srcSet}
  sizes="(max-width: 768px) 100vw, 768px"
  alt={article.title}
/>
```

### `lynkow.media.transform(url, options)`

Returns a single transformed URL. Use for thumbnails, avatars, or any specific size:

```tsx
const thumbnail = lynkow.media.transform(article.featuredImage, {
  width: 400,
  height: 300,
  fit: 'cover',
})

// Result: "https://cdn.../image.jpg?w=400&h=300&fit=cover"
```

### Pre-computed Variants

Every content response that includes a `featuredImage` also includes `featuredImageVariants` with pre-computed URLs for common use cases:

```typescript
{
  featuredImage: "https://cdn.../original.jpg",
  featuredImageVariants: {
    thumbnail: "https://cdn.../original.jpg?w=150&h=150&fit=cover",
    card: "https://cdn.../original.jpg?w=600&h=400&fit=cover",
    hero: "https://cdn.../original.jpg?w=1200&h=630&fit=cover",
    og: "https://cdn.../original.jpg?w=1200&h=630&fit=cover"
  }
}
```

Use these directly when you do not need custom sizes:

```tsx
<img src={article.featuredImageVariants?.card} alt={article.title} />
```

## ISR Configuration

Incremental Static Regeneration (ISR) is configured at the client level via `fetchOptions`, but you can override it per-page or per-request.

### Global setting (in `lib/lynkow.ts`)

```typescript
export const lynkow = createClient({
  siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
  fetchOptions: {
    next: { revalidate: 60 }, // Revalidate every 60 seconds
  },
})
```

### Per-page override

Use the Next.js route segment config to override the revalidation interval for a specific page:

```tsx
// app/blog/page.tsx
export const revalidate = 30 // Revalidate listing every 30 seconds
```

```tsx
// app/blog/[slug]/page.tsx
export const revalidate = 120 // Articles change less often, 2-minute cache
```

### Recommended intervals

| Page | `revalidate` | Rationale |
| --- | --- | --- |
| Blog listing | `30` - `60` | New articles should appear quickly |
| Single article | `60` - `300` | Content changes are less frequent |
| Category page | `60` - `120` | Updates when articles are added/removed |
| Tag filter | `60` | Same as listing |


### On-demand revalidation

For instant updates when content is published, you can set up a webhook in Lynkow that calls a Next.js revalidation API route:

```typescript
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-webhook-secret')

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
  }

  const body = await request.json()

  // Revalidate the specific article and the listing
  if (body.slug) {
    revalidatePath(`/blog/${body.slug}`)
  }
  revalidatePath('/blog')

  return NextResponse.json({ revalidated: true })
}
```

Configure this URL as a webhook endpoint in the Lynkow dashboard under **Settings > Webhooks**, triggered on `content.published` and `content.updated` events. Lynkow signs webhook payloads with HMAC-SHA256 via the `X-Webhook-Signature` header, which you can verify for additional security.

## Complete Root Layout

For reference, here is a minimal root layout that ties everything together:

```tsx
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  title: {
    template: '%s | My Blog',
    default: 'My Blog',
  },
  description: 'A blog powered by Lynkow and Next.js',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-white text-gray-900 antialiased">
        <header className="border-b border-gray-200">
          <nav className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
            <a href="/" className="text-xl font-bold">
              My Blog
            </a>
            <div className="flex gap-6">
              <a href="/blog" className="text-gray-600 hover:text-gray-900">
                Articles
              </a>
            </div>
          </nav>
        </header>
        {children}
      </body>
    </html>
  )
}
```

## Summary

This guide covered:

1. **SDK setup** with a shared client instance and ISR caching
2. **Blog listing** with paginated content and tag filtering
3. **Single article pages** with full HTML rendering, author info, and metadata
4. **Category pages** with flat and tree-based navigation
5. **SEO** with `generateMetadata()` and JSON-LD structured data injection
6. **Responsive images** using `srcset`, `transform`, and pre-computed variants
7. **ISR configuration** with global defaults and per-page overrides
8. **On-demand revalidation** via webhooks for instant content updates

For more information, see the [API Reference](/docs/sdk) and the [Quick Start guide](/docs/sdk/getting-started/quick-start).