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.jsonSetup
If you are starting from scratch:
npx create-next-app@latest my-blog --typescript --app --tailwind
cd my-blog
npm install lynkowAdd your Site ID to .env.local:
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-id-hereShared 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.
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:
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.
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():
const thumbnailUrl = lynkow.media.transform(imageUrl, {
width: 300,
height: 200,
fit: 'cover',
})Pagination
Create components/pagination.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.
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:
<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:
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.
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.
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():
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:
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:
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, ..."<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:
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:
{
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:
<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)
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:
// app/blog/page.tsx
export const revalidate = 30 // Revalidate listing every 30 seconds// app/blog/[slug]/page.tsx
export const revalidate = 120 // Articles change less often, 2-minute cacheRecommended intervals
Page |
| Rationale |
|---|---|---|
Blog listing |
| New articles should appear quickly |
Single article |
| Content changes are less frequent |
Category page |
| Updates when articles are added/removed |
Tag filter |
| 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:
// 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:
// 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:
SDK setup with a shared client instance and ISR caching
Blog listing with paginated content and tag filtering
Single article pages with full HTML rendering, author info, and metadata
Category pages with flat and tree-based navigation
SEO with
generateMetadata()and JSON-LD structured data injectionResponsive images using
srcset,transform, and pre-computed variantsISR configuration with global defaults and per-page overrides
On-demand revalidation via webhooks for instant content updates
For more information, see the API Reference and the Quick Start guide.