Routing

Catmint uses file-based routing to map your directory structure to URL paths. Every directory inside app/ that contains a page.tsx file becomes a route. This guide covers static routes, dynamic segments, catch-all routes, route groups, priority scoring, and programmatic navigation.

Static Routes

The simplest routes are static. The directory path directly maps to the URL:

app/page.tsx                -> /
app/about/page.tsx          -> /about
app/blog/page.tsx           -> /blog
app/contact/us/page.tsx     -> /contact/us

Each page.tsx file must export a default React component. The component receives no special props by default -- use hooks like useParams() and useSearch() from catmint/hooks to access route data.

// app/about/page.tsx
export default function AboutPage() {
  return (
    <div>
      <h1>About Us</h1>
      <p>This page is served at /about.</p>
    </div>
  )
}

Dynamic Segments

Wrap a directory name in square brackets to create a dynamic segment. The matched value is available via useParams():

app/blog/[slug]/page.tsx              -> /blog/:slug
app/users/[id]/page.tsx              -> /users/:id
app/shop/[category]/[item]/page.tsx  -> /shop/:category/:item
// app/blog/[slug]/page.tsx
import { useParams } from 'catmint/hooks'

export default function BlogPostPage() {
  const { slug } = useParams()

  return (
    <div>
      <h1>Blog Post</h1>
      <p>Viewing post: {slug}</p>
    </div>
  )
}

Dynamic segments match exactly one URL segment. Visiting /blog/hello-world sets slug to "hello-world". You can chain multiple dynamic segments in a single path.

Catch-All Segments

Use the spread syntax inside brackets to match any number of path segments. The parameter value is an array of strings:

app/docs/[...path]/page.tsx  -> /docs/*
// app/docs/[...path]/page.tsx
import { useParams } from 'catmint/hooks'

export default function DocsPage() {
  const { path } = useParams()
  // /docs/guides/routing -> path = ["guides", "routing"]

  return (
    <div>
      <h1>Documentation</h1>
      <p>Path: {path.join(' / ')}</p>
    </div>
  )
}

Catch-all segments require at least one segment to match. /docs alone will not match a catch-all route at app/docs/[...path]/page.tsx.

Optional Catch-All Segments

Double brackets make the catch-all optional, meaning the route also matches the parent path with no additional segments:

app/docs/[[...path]]/page.tsx  -> /docs and /docs/*
// app/docs/[[...path]]/page.tsx
import { useParams } from 'catmint/hooks'

export default function DocsPage() {
  const { path } = useParams()
  // /docs          -> path = []
  // /docs/intro    -> path = ["intro"]
  // /docs/a/b/c    -> path = ["a", "b", "c"]

  if (path.length === 0) {
    return <h1>Documentation Index</h1>
  }

  return <h1>Docs: {path.join(' / ')}</h1>
}

When accessed at /docs with no trailing segments, path is an empty array.

Route Groups

Directories wrapped in parentheses create route groups. They organize files and allow different layouts to be applied without adding a segment to the URL:

app/
  (marketing)/
    layout.tsx            # Marketing layout
    page.tsx              # Renders at /
    pricing/
      page.tsx            # Renders at /pricing
  (dashboard)/
    layout.tsx            # Dashboard layout
    settings/
      page.tsx            # Renders at /settings
    profile/
      page.tsx            # Renders at /profile

The group name (e.g., (marketing)) is stripped from the URL entirely. Route groups are useful for applying different layouts or middleware to different sections of your application while keeping URL paths clean.

Route groups can contain their own layout.tsx and middleware.ts files. These apply only to pages within that group.

Route Priority Scoring

When multiple routes could match the same URL, Catmint uses a priority scoring system to determine the best match. Each segment type contributes a different weight:

Segment TypeWeightExample
Static4xabout
Dynamic3x[slug]
Catch-all2x[...path]
Optional catch-all1x[[...path]]

The total score for a route is the sum of weights across all its segments. Higher scores win. For example, given these routes:

app/blog/latest/page.tsx          # Score: 4 + 4 = 8 (static + static)
app/blog/[slug]/page.tsx          # Score: 4 + 3 = 7 (static + dynamic)
app/blog/[...path]/page.tsx       # Score: 4 + 2 = 6 (static + catch-all)
app/[[...path]]/page.tsx          # Score: 1         (optional catch-all)

A request to /blog/latest matches the first route (score 8) because static segments are always preferred over dynamic ones.

Trailing Slash Handling

Catmint normalizes trailing slashes by default. Both /about and /about/ resolve to the same route. You can configure trailing slash behavior in catmint.config.ts:

// catmint.config.ts
import { defineConfig } from 'catmint/config'

export default defineConfig({
  mode: 'fullstack',
  routing: {
    trailingSlash: 'ignore',  // 'ignore' | 'always' | 'never'
  },
})
OptionBehavior
"ignore"Both forms match the same route (default)
"always"Redirects to the trailing slash version
"never"Redirects to the non-trailing-slash version

Explicit Routing with defineRoutes

For cases where file-based routing is insufficient, you can define routes explicitly using defineRoutes() from catmint/routing. This is useful for aliasing paths, programmatically generating routes, or integrating with external data sources:

// catmint.config.ts
import { defineConfig } from 'catmint/config'
import { defineRoutes } from 'catmint/routing'

export default defineConfig({
  mode: 'fullstack',
  routes: defineRoutes((route) => {
    route('/legacy-about', './app/about/page.tsx')
    route('/old-blog/:slug', './app/blog/[slug]/page.tsx')
    route('/products/:category/:id', './app/shop/[category]/[item]/page.tsx')
  }),
})

Routes defined with defineRoutes() are merged with the file-based routes. If a conflict occurs, explicitly defined routes take precedence.

The Link Component

Use the Link component from catmint/link for client-side navigation. It renders an anchor tag and intercepts clicks to perform navigation without a full page reload:

// app/components/Nav.client.tsx
import { Link } from 'catmint/link'

export function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog">Blog</Link>
      <Link href="/blog/hello-world">A Post</Link>
    </nav>
  )
}

The Link component accepts all standard anchor attributes in addition to these:

PropTypeDescription
hrefstringThe destination URL path
prefetchbooleanPrefetch the route on hover (default: true)
replacebooleanReplace current history entry instead of pushing

Programmatic Navigation with redirect

Use the redirect() function from catmint/routing to perform server-side redirects. It works in server components, middleware, and server functions:

// app/old-page/page.tsx
import { redirect } from 'catmint/routing'

export default function OldPage() {
  redirect('/new-page')
}

// With a custom status code
export default function MovedPage() {
  redirect('/new-location', 301)
}

The redirect() function throws internally to halt rendering and send the redirect response. It accepts an optional second argument for the HTTP status code (defaults to 302).

Query Parameters

Access query string parameters with the useSearch() hook from catmint/hooks:

// app/search/page.tsx
import { useSearch } from 'catmint/hooks'

export default function SearchPage() {
  const { q, page } = useSearch()
  // /search?q=catmint&page=2 -> q = "catmint", page = "2"

  return (
    <div>
      <h1>Search results for: {q}</h1>
      <p>Page {page ?? 1}</p>
    </div>
  )
}

Next Steps

  • Layouts -- nested layout chains and layout persistence
  • Middleware -- intercept requests with the onion execution model
  • API Endpoints -- define HTTP handlers alongside your pages