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:

  1. One route handles everything. No need to maintain parallel route files for contents and categories.

  2. URL structure changes are transparent. If an editor moves a category or changes the blog URL mode, the same catch-all route keeps working.

  3. 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.local

Shared Client

Create lib/lynkow.ts:

TypeScript
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:

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:

TypeScript
{
  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.

TypeScript
// 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:

tsx
// ---------------------------------------------------------------------------
// 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:

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:

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:

TypeScript
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:

TypeScript
{
  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():

tsx
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:

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:

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:

TypeScript
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:

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

  1. Catch-all route app/blog/[...slug]/page.tsx handles both article and category pages in a single file.

  2. paths.list() returns all routable paths with segments arrays, used in generateStaticParams() for static generation.

  3. paths.resolve() takes a URL path and returns a discriminated union -- use isContentResolve() and isCategoryResolve() type guards to determine what was matched.

  4. Content pages receive the full article with body HTML, author, categories, tags, and SEO fields.

  5. Category pages receive the category metadata and a paginated list of content summaries.

  6. generateMetadata() produces SEO tags for both content and category pages from a single function.

  7. Redirect handling runs in Next.js middleware using paths.matchRedirect(), sending proper HTTP redirect responses (301/302/307/308) before the page renders.

  8. 404 handling catches errors from paths.resolve() using isLynkowError() and calls notFound() to render a custom 404 page.