This guide shows how to build a single Next.js catch-all route that handles both article pages and category listing pages using the Lynkow path resolution API. Instead of creating separate routes for /blog/[slug] and /blog/category/[slug], a single [...slug] route resolves any path -- whether it points to a content article or a category -- and renders the correct page.
Why Catch-all Routes
In Lynkow, the blog URL mode can be flat or nested:
Flat mode: all articles live at
/blog/my-article. Categories do not have their own pages.Nested mode: articles live under their category hierarchy, e.g.
/blog/guides/my-article, and categories have listing pages at/blog/guides.
When nested mode is enabled, a URL like /blog/guides could be a category listing, while /blog/guides/getting-started could be an article. Rather than guessing the structure in your frontend routing, you call paths.resolve() and it tells you exactly what a given path resolves to.
This approach has three advantages:
One route handles everything. No need to maintain parallel route files for contents and categories.
URL structure changes are transparent. If an editor moves a category or changes the blog URL mode, the same catch-all route keeps working.
Redirects are handled in middleware. Lynkow's redirect matching runs before the page renders, so old URLs get proper HTTP redirects.
Project Structure
my-site/
├── app/
│ ├── blog/
│ │ └── [...slug]/
│ │ └── page.tsx # Catch-all: resolves content or category
│ └── layout.tsx
├── components/
│ ├── article-page.tsx # Content rendering
│ ├── category-page.tsx # Category listing
│ ├── article-card.tsx # Reusable card (from blog guide)
│ └── pagination.tsx # Pagination controls (from blog guide)
├── middleware.ts # Redirect handling
├── lib/
│ └── lynkow.ts
└── .env.localShared Client
Create lib/lynkow.ts:
import { createClient } from 'lynkow'
export const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
fetchOptions: {
next: { revalidate: 60 },
},
})Static Generation with generateStaticParams
The paths.list() endpoint returns every routable path on your site -- both content paths and category paths. Each path includes a segments array that maps directly to the slug parameter in your catch-all route.
Create app/blog/[...slug]/page.tsx:
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { isContentResolve, isCategoryResolve } from 'lynkow'
import { ArticlePage } from '@/components/article-page'
import { CategoryPage } from '@/components/category-page'
type Params = Promise<{ slug: string[] }>
type SearchParams = Promise<{ page?: string }>
// ---------------------------------------------------------------------------
// Static generation: pre-render all paths at build time
// ---------------------------------------------------------------------------
export async function generateStaticParams() {
const { paths } = await lynkow.paths.list()
return paths.map((p) => ({
slug: p.segments,
}))
}The paths.list() response looks like this:
{
paths: [
{ path: '/guides', segments: ['guides'], type: 'category', locale: 'en', lastModified: '2025-06-01T...' },
{ path: '/guides/getting-started', segments: ['guides', 'getting-started'], type: 'content', locale: 'en', lastModified: '2025-06-10T...' },
{ path: '/news', segments: ['news'], type: 'category', locale: 'en', lastModified: '2025-05-20T...' },
{ path: '/news/product-launch', segments: ['news', 'product-launch'], type: 'content', locale: 'en', lastModified: '2025-06-12T...' },
],
blogUrlMode: 'nested'
}Each segments array becomes one set of static params. Next.js pre-renders /blog/guides, /blog/guides/getting-started, /blog/news, and /blog/news/product-launch at build time.
Path Resolution
The paths.resolve() method takes a URL path and returns a discriminated union: either a ContentResolveResponse or a CategoryResolveResponse.
// Content resolve response
{
type: 'content',
locale: 'en',
blogUrlMode: 'nested',
content: {
id: '...',
title: 'Getting Started',
slug: 'getting-started',
path: '/guides/getting-started',
body: '<h2>Welcome</h2><p>...</p>',
excerpt: '...',
featuredImage: '...',
featuredImageVariants: { ... },
metaTitle: '...',
metaDescription: '...',
author: { id: '...', fullName: 'John', avatarUrl: '...' },
categories: [{ id: '...', name: 'Guides', slug: 'guides', path: '/guides' }],
tags: [...],
customData: null,
structuredData: { ... },
publishedAt: '2025-06-10T...',
// ...
}
}
// Category resolve response
{
type: 'category',
locale: 'en',
blogUrlMode: 'nested',
category: {
id: '...',
name: 'Guides',
slug: 'guides',
path: '/guides',
description: 'Step-by-step tutorials',
image: '...',
imageVariants: { ... },
},
contents: {
data: [
{ id: '...', title: 'Getting Started', slug: 'getting-started', path: '/guides/getting-started', excerpt: '...', ... },
// ...
],
meta: { total: 12, perPage: 20, currentPage: 1, lastPage: 1 }
}
}Page Component
Add the page component and metadata generation to the same file:
// ---------------------------------------------------------------------------
// SEO metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
}: {
params: Params
}): Promise<Metadata> {
const { slug } = await params
const urlPath = '/' + slug.join('/')
try {
const resolved = await lynkow.paths.resolve(urlPath)
if (isContentResolve(resolved)) {
const { content } = resolved
return {
title: content.metaTitle || content.title,
description:
content.metaDescription || content.excerpt || undefined,
openGraph: {
title: content.metaTitle || content.title,
description:
content.metaDescription || content.excerpt || undefined,
type: 'article',
publishedTime: content.publishedAt,
authors: content.author
? [content.author.fullName]
: undefined,
images: content.ogImage
? [{ url: content.ogImage }]
: content.featuredImage
? [{ url: content.featuredImage }]
: undefined,
},
twitter: {
card: 'summary_large_image',
title: content.metaTitle || content.title,
description:
content.metaDescription || content.excerpt || undefined,
},
}
}
if (isCategoryResolve(resolved)) {
const { category, contents } = resolved
return {
title: category.name,
description:
category.description ||
`Browse ${contents.meta.total} articles in ${category.name}`,
openGraph: {
title: category.name,
description:
category.description ||
`Browse ${contents.meta.total} articles in ${category.name}`,
...(category.image && { images: [{ url: category.image }] }),
},
}
}
} catch {
// Fall through to default
}
return { title: 'Not Found' }
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default async function CatchAllPage({
params,
searchParams,
}: {
params: Params
searchParams: SearchParams
}) {
const { slug } = await params
const { page: pageParam } = await searchParams
const urlPath = '/' + slug.join('/')
let resolved
try {
resolved = await lynkow.paths.resolve(urlPath, {
page: pageParam ? Number(pageParam) : undefined,
})
} catch {
notFound()
}
if (isContentResolve(resolved)) {
return <ArticlePage content={resolved.content} />
}
if (isCategoryResolve(resolved)) {
return (
<CategoryPage
category={resolved.category}
contents={resolved.contents}
basePath={`/blog${urlPath}`}
/>
)
}
notFound()
}Content Rendering Component
Create components/article-page.tsx:
import Link from 'next/link'
interface ArticlePageProps {
content: {
title: string
body: string
excerpt: string | null
featuredImage: string | null
featuredImageVariants: Record<string, string> | null
publishedAt: string
author: {
id: string
fullName: string
avatarUrl: string | null
} | null
categories: {
id: string
name: string
slug: string
path: string
}[]
tags: {
id: string
name: string
slug: string
}[]
customData: Record<string, any> | null
structuredData: Record<string, any> | null
}
}
export function ArticlePage({ content }: ArticlePageProps) {
const formattedDate = new Date(content.publishedAt).toLocaleDateString(
'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' }
)
return (
<>
{/* JSON-LD structured data */}
{content.structuredData && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(content.structuredData),
}}
/>
)}
<main className="max-w-3xl mx-auto px-4 py-12">
<article>
{/* Categories */}
{content.categories.length > 0 && (
<div className="flex gap-2 mb-4">
{content.categories.map((category) => (
<Link
key={category.id}
href={`/blog${category.path}`}
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">
{content.title}
</h1>
{/* Author and date */}
<div className="flex items-center gap-4 mt-6 text-gray-600">
{content.author && (
<div className="flex items-center gap-3">
{content.author.avatarUrl && (
<img
src={content.author.avatarUrl}
alt={content.author.fullName}
className="w-10 h-10 rounded-full"
/>
)}
<span className="font-medium">
{content.author.fullName}
</span>
</div>
)}
<time dateTime={content.publishedAt}>{formattedDate}</time>
</div>
{/* Featured image */}
{content.featuredImage && (
<img
src={
content.featuredImageVariants?.hero ||
content.featuredImage
}
alt={content.title}
className="w-full rounded-lg mt-8"
/>
)}
{/* Article body */}
<div
className="mt-10 prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
{/* Tags */}
{content.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">
{content.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>
</>
)
}Category Rendering Component
Create components/category-page.tsx:
import { ArticleCard } from './article-card'
import { Pagination } from './pagination'
interface CategoryPageProps {
category: {
id: string
name: string
slug: string
path: string
description: string | null
image: string | null
}
contents: {
data: {
id: string
title: string
slug: string
path: string
excerpt: string | null
featuredImage: string | null
featuredImageVariants: Record<string, string> | null
publishedAt: string
}[]
meta: {
total: number
perPage: number
currentPage: number
lastPage: number
}
}
basePath: string
}
export function CategoryPage({
category,
contents,
basePath,
}: CategoryPageProps) {
return (
<main className="max-w-6xl mx-auto px-4 py-12">
<header className="mb-12">
{category.image && (
<img
src={category.image}
alt={category.name}
className="w-full h-48 object-cover rounded-lg mb-6"
/>
)}
<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.path}
href={`/blog${article.path}`}
excerpt={article.excerpt}
featuredImage={article.featuredImage}
featuredImageVariants={article.featuredImageVariants}
publishedAt={article.publishedAt}
/>
))}
</div>
)}
<Pagination
currentPage={contents.meta.currentPage}
lastPage={contents.meta.lastPage}
basePath={basePath}
/>
</main>
)
}Redirect Handling in Middleware
Lynkow supports redirect rules configured in the dashboard (301, 302, 307, 308). Use paths.matchRedirect() in Next.js middleware to intercept requests before they reach the page component.
Create middleware.ts at the project root:
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from 'lynkow'
const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
})
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Only check redirects for blog paths
if (!pathname.startsWith('/blog')) {
return NextResponse.next()
}
// Strip the /blog prefix to get the path Lynkow knows about
const lynkowPath = pathname.replace(/^\/blog/, '') || '/'
try {
const redirect = await lynkow.paths.matchRedirect(lynkowPath)
if (redirect) {
const destinationUrl = new URL(
`/blog${redirect.target}`,
request.url
)
// Preserve query string if the redirect rule says so
if (redirect.preserveQueryString) {
request.nextUrl.searchParams.forEach((value, key) => {
destinationUrl.searchParams.set(key, value)
})
}
return NextResponse.redirect(destinationUrl, redirect.statusCode)
}
} catch {
// If the redirect API fails, let the request continue to the page
}
return NextResponse.next()
}
export const config = {
matcher: '/blog/:path*',
}The paths.matchRedirect() method returns null if no redirect matches, or a Redirect object:
{
source: '/old-article',
target: '/guides/new-article',
statusCode: 301, // 301 | 302 | 307 | 308
preserveQueryString: true
}The middleware sends the appropriate HTTP redirect response before Next.js even renders the page, so search engines receive the correct status code.
404 Handling
When paths.resolve() receives a path that does not match any content or category, the SDK throws an error with a 404 status. Catch it and call notFound():
import { notFound } from 'next/navigation'
import { isLynkowError } from 'lynkow'
export default async function CatchAllPage({
params,
}: {
params: Params
}) {
const { slug } = await params
const urlPath = '/' + slug.join('/')
try {
const resolved = await lynkow.paths.resolve(urlPath)
// ... render content or category
} catch (error) {
if (isLynkowError(error) && error.status === 404) {
notFound()
}
// Re-throw unexpected errors
throw error
}
}Create a custom 404 page at app/blog/[...slug]/not-found.tsx:
import Link from 'next/link'
export default function NotFound() {
return (
<main className="max-w-3xl mx-auto px-4 py-24 text-center">
<h1 className="text-6xl font-bold text-gray-200">404</h1>
<h2 className="mt-4 text-2xl font-semibold text-gray-900">
Page not found
</h2>
<p className="mt-2 text-gray-600">
The page you are looking for does not exist or has been moved.
</p>
<Link
href="/blog"
className="inline-block mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Blog
</Link>
</main>
)
}Complete Implementation
Here is the full catch-all page file with all sections assembled together.
app/blog/[...slug]/page.tsx:
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { isContentResolve, isCategoryResolve, isLynkowError } from 'lynkow'
import { ArticlePage } from '@/components/article-page'
import { CategoryPage } from '@/components/category-page'
type Params = Promise<{ slug: string[] }>
type SearchParams = Promise<{ page?: string }>
// ---------------------------------------------------------------------------
// Static generation: pre-render all known paths at build time
// ---------------------------------------------------------------------------
export async function generateStaticParams() {
const { paths } = await lynkow.paths.list()
return paths.map((p) => ({
slug: p.segments,
}))
}
// ---------------------------------------------------------------------------
// SEO metadata: generates title, description, and Open Graph tags
// ---------------------------------------------------------------------------
export async function generateMetadata({
params,
}: {
params: Params
}): Promise<Metadata> {
const { slug } = await params
const urlPath = '/' + slug.join('/')
try {
const resolved = await lynkow.paths.resolve(urlPath)
if (isContentResolve(resolved)) {
const { content } = resolved
return {
title: content.metaTitle || content.title,
description:
content.metaDescription || content.excerpt || undefined,
openGraph: {
title: content.metaTitle || content.title,
description:
content.metaDescription || content.excerpt || undefined,
type: 'article',
publishedTime: content.publishedAt,
authors: content.author
? [content.author.fullName]
: undefined,
images: content.ogImage
? [{ url: content.ogImage }]
: content.featuredImage
? [{ url: content.featuredImage }]
: undefined,
},
twitter: {
card: 'summary_large_image',
title: content.metaTitle || content.title,
description:
content.metaDescription || content.excerpt || undefined,
},
}
}
if (isCategoryResolve(resolved)) {
const { category, contents } = resolved
return {
title: category.name,
description:
category.description ||
`Browse ${contents.meta.total} articles in ${category.name}`,
openGraph: {
title: category.name,
description:
category.description ||
`Browse ${contents.meta.total} articles in ${category.name}`,
...(category.image && {
images: [{ url: category.image }],
}),
},
}
}
} catch {
// Fall through to default
}
return { title: 'Not Found' }
}
// ---------------------------------------------------------------------------
// Page component: resolves the path and renders content or category
// ---------------------------------------------------------------------------
export default async function CatchAllPage({
params,
searchParams,
}: {
params: Params
searchParams: SearchParams
}) {
const { slug } = await params
const { page: pageParam } = await searchParams
const urlPath = '/' + slug.join('/')
let resolved
try {
resolved = await lynkow.paths.resolve(urlPath, {
page: pageParam ? Number(pageParam) : undefined,
})
} catch (error) {
if (isLynkowError(error) && error.status === 404) {
notFound()
}
throw error
}
if (isContentResolve(resolved)) {
return <ArticlePage content={resolved.content} />
}
if (isCategoryResolve(resolved)) {
return (
<CategoryPage
category={resolved.category}
contents={resolved.contents}
basePath={`/blog${urlPath}`}
/>
)
}
notFound()
}middleware.ts:
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from 'lynkow'
const lynkow = createClient({
siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
})
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
if (!pathname.startsWith('/blog')) {
return NextResponse.next()
}
const lynkowPath = pathname.replace(/^\/blog/, '') || '/'
try {
const redirect = await lynkow.paths.matchRedirect(lynkowPath)
if (redirect) {
const destinationUrl = new URL(
`/blog${redirect.target}`,
request.url
)
if (redirect.preserveQueryString) {
request.nextUrl.searchParams.forEach((value, key) => {
destinationUrl.searchParams.set(key, value)
})
}
return NextResponse.redirect(destinationUrl, redirect.statusCode)
}
} catch {
// Let the request continue on redirect API failure
}
return NextResponse.next()
}
export const config = {
matcher: '/blog/:path*',
}app/blog/[...slug]/not-found.tsx:
import Link from 'next/link'
export default function NotFound() {
return (
<main className="max-w-3xl mx-auto px-4 py-24 text-center">
<h1 className="text-6xl font-bold text-gray-200">404</h1>
<h2 className="mt-4 text-2xl font-semibold text-gray-900">
Page not found
</h2>
<p className="mt-2 text-gray-600">
The page you are looking for does not exist or has been moved.
</p>
<Link
href="/blog"
className="inline-block mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Blog
</Link>
</main>
)
}Summary
Catch-all route
app/blog/[...slug]/page.tsxhandles both article and category pages in a single file.paths.list()returns all routable paths withsegmentsarrays, used ingenerateStaticParams()for static generation.paths.resolve()takes a URL path and returns a discriminated union -- useisContentResolve()andisCategoryResolve()type guards to determine what was matched.Content pages receive the full article with body HTML, author, categories, tags, and SEO fields.
Category pages receive the category metadata and a paginated list of content summaries.
generateMetadata()produces SEO tags for both content and category pages from a single function.Redirect handling runs in Next.js middleware using
paths.matchRedirect(), sending proper HTTP redirect responses (301/302/307/308) before the page renders.404 handling catches errors from
paths.resolve()usingisLynkowError()and callsnotFound()to render a custom 404 page.