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.

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

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 and the Quick Start guide.