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

# Catch-all Routes & Path Resolution

**Publié le** : 2026-08-14
**Catégorie** : Advanced

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.

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

## 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`. The `type` field is the discriminant; narrow it with the `isContentResolve()` and `isCategoryResolve()` type guards (used in the page component below).

```typescript
// Discriminated union returned by paths.resolve()
type Resolved =
  | { type: 'content'; locale: string; blogUrlMode: string; content: { /* article fields */ } }
  | {
      type: 'category'
      locale: string
      blogUrlMode: string
      category: { /* category fields */ }
      contents: { data: [ /* content summaries */ ]; meta: { /* pagination */ } }
    }
```

See the full `ContentResolveResponse` and `CategoryResolveResponse` shapes in the [SDK Types reference](/docs/sdk/types).

## 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.