GioJSdocs
On this page

Dynamic Routes

Folders named [param], [...param] or [[...param]] match URL segments that are not known in advance and pass them to the route as params.

text
app/
  blog/[slug]/page.tsx           /blog/:slug       /blog/hello
  docs/[...path]/page.tsx        /docs/*path       /docs/a, /docs/a/b/c
  shop/[[...filters]]/page.tsx   /shop/*filters?   /shop, /shop/red/large

Reference

Convention

FolderPatternMatchesParam value
[id]:idExactly one segment/posts/7 → { id: '7' }
[...slug]*slugOne or more segments (not the parent URL)/docs/a/b → { slug: 'a/b' }
[[...slug]]*slug?Zero or more segments, so the parent URL too/shop → { slug: '' }, /shop/a/b → { slug: 'a/b' }

The pattern column is how GioJS writes the route everywhere else: in gio routes, in .gio/routes.d.ts, in the type helpers (PageProps<'/blog/:slug'>) and in href().

Param values

  • Every value is a string. A catch-all is one string that keeps its / separators, not an array as in Next.js: split it yourself (slug.split('/')). An optional catch-all that matched nothing is ''.
  • Values come from the path as the client sent it, after the server's cleanup: repeated and trailing slashes are collapsed and escapes of unreserved characters decoded (%41 is A). Other escapes stay as they are: /blog/caf%C3%A9 gives { slug: 'caf%C3%A9' }, so use decodeURIComponent() when you need the text. Paths with . or .. segments, or a broken % escape, are refused with 400 before any route runs.

Where params arrive

InRead them from
A page without getServerSidePropsThe params prop
getServerSideProps, generateMetadatactx.params
A page action, a route.ts handlerreq.params
A wsHandlersocket.params
Any component, layouts includeduseParams()

Name rules

Malformed folder names stop startup with an error naming the file:

  • A name may not start with . and may not contain [, ], /, ?, : or *. [id?] or [a]b fails with unsupported dynamic segment "[id?]" - use [name], [...name] or [[...name]].
  • A folder named :id or *rest fails too: it would read back as a pattern.
  • A param name may appear once per route ([id]/edit/[id] fails), and a catch-all must be the last segment ([...a]/b fails).

Matching order

When several routes match a URL, the most specific one answers. Patterns are compared segment by segment from the left, and the first segment that differs decides:

  1. a static segment (about)
  2. a dynamic segment ([slug])
  3. a catch-all ([...slug])
  4. the end of the pattern
  5. an optional catch-all ([[...slug]])

So /blog/about renders blog/about/page.tsx even next to blog/[slug]/page.tsx, and with shop/page.tsx and shop/[[...path]]/page.tsx, /shop renders the static page and /shop/a the catch-all. Pages and route.ts files share this order, so a catch-all route.ts never shadows a more specific page.

Conflicts

Two routes that would match exactly the same URLs stop startup, because no order could pick between them:

text
route conflict: app/posts/[id]/page.tsx and app/posts/[slug]/page.tsx both resolve to "/posts/:id" and "/posts/:slug" (the same URLs under different param names) - every URL must be served by exactly one file

docs/[...a] next to docs/[[...b]] fails the same way: the catch-all outranks the optional one on every URL below /docs, which would leave it only /docs itself. Keep one, and add docs/page.tsx if /docs needs its own page.

Examples

A docs section with a catch-all

app/docs/[...path]/page.tsx
import React from 'react';
import { notFound } from '@gio.js/core';
import type { GetServerSideProps, InferPageProps } from '@gio.js/core';
import { loadDoc } from '../../../lib/docs';

export const getServerSideProps: GetServerSideProps<{ title: string; html: string }, '/docs/*path'> =
  async (ctx) => {
    const segments = ctx.params.path.split('/'); // '/docs/guides/setup' -> ['guides', 'setup']
    const doc = await loadDoc(segments);
    if (doc === null) notFound();
    return { props: { title: doc.title, html: doc.html } };
  };

export default function DocPage({ title, html }: InferPageProps<typeof getServerSideProps>) {
  return (
    <article>
      <h1>{title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  );
}

An optional catch-all for filters

app/shop/[[...filters]]/page.tsx
import React from 'react';
import type { PageProps } from '@gio.js/core';

export default function Shop({ params }: PageProps<'/shop/*filters?'>) {
  const filters = params.filters ? params.filters.split('/') : [];
  return <h1>{filters.length === 0 ? 'All products' : `Filtered by ${filters.join(', ')}`}</h1>;
}

Pre-render dynamic routes for a static export

The server renders any param on demand. gio export needs the list, from getStaticPaths; a catch-all param may be given as a string or as its segments:

app/docs/[...path]/page.tsx
import type { GetStaticPaths } from '@gio.js/core';

export const getStaticPaths: GetStaticPaths<'/docs/*path'> = () => ({
  paths: [
    { params: { path: 'getting-started' } },
    { params: { path: ['guides', 'setup'] } },
  ],
});

Good to know

  • On a soft navigation between two URLs of the same route (/blog/a to /blog/b) the page remounts, and so does every layout inside the dynamic folder, so no state leaks from one value to the next.
  • Layouts, loading.tsx, error.tsx and not-found.tsx work inside dynamic folders and apply to every URL below them.
  • In .gio/routes.d.ts a dynamic param is string and an optional catch-all string | undefined ({ filters?: string }), so typed code handles the empty case.
  • href('/shop/*filters?') without the param gives /shop; values are URL-encoded per segment, catch-alls keeping their slashes.

Version history

VersionChanges
v0.1.0-beta.8[...slug] matches one or more segments and [[...slug]] zero or more, as one /-joined string. Deterministic precedence shared by pages and route.ts; conflicting files and malformed names stop startup.
v0.1.0-beta.1Introduced: [param] folders.