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.
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/largeReference
Convention
| Folder | Pattern | Matches | Param value |
|---|---|---|---|
[id] | :id | Exactly one segment | /posts/7 → { id: '7' } |
[...slug] | *slug | One 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 (
%41isA). Other escapes stay as they are:/blog/caf%C3%A9gives{ slug: 'caf%C3%A9' }, so usedecodeURIComponent()when you need the text. Paths with.or..segments, or a broken%escape, are refused with400before any route runs.
Where params arrive
| In | Read them from |
|---|---|
A page without getServerSideProps | The params prop |
getServerSideProps, generateMetadata | ctx.params |
A page action, a route.ts handler | req.params |
A wsHandler | socket.params |
| Any component, layouts included | useParams() |
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]bfails withunsupported dynamic segment "[id?]" - use [name], [...name] or [[...name]]. - A folder named
:idor*restfails 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]/bfails).
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:
- a static segment (
about) - a dynamic segment (
[slug]) - a catch-all (
[...slug]) - the end of the pattern
- 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:
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 filedocs/[...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
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
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:
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/ato/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.tsxandnot-found.tsxwork inside dynamic folders and apply to every URL below them. - In
.gio/routes.d.tsa dynamic param isstringand an optional catch-allstring | 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.
Related
- Layouts & Pages: Dynamic routes
- Route Groups, Private Folders
useParams,href, TypeScriptgio routes- lists every pattern without starting the server.
Version history
| Version | Changes |
|---|---|
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.1 | Introduced: [param] folders. |