# Routes

> How to test every route for hydration errors: route discovery per framework, example values for dynamic routes, sitemaps, crawling, globs and the route cache.

Source: https://hydration.jscrate.dev/docs/routes
Last updated: 2026-09-18

To test every route for hydration errors, hydration-proof combines the routes it
discovers from your framework and build output with the paths, dynamic values,
sitemap entries and crawled links you configure. Every page in the report
records where its route came from. A dynamic route needs example values, or it
is skipped with a note.

## Where routes come from

| Source       | Meaning                                                                                        |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `config`     | `routes.paths`, `routes.dynamic` or `--route`                                                  |
| `discovered` | Found by the framework adapter, for example in `app/` or `pages/`                              |
| `manifest`   | Example values of a dynamic route, read from the Next.js build output (including i18n locales) |
| `sitemap`    | Listed in a sitemap                                                                            |
| `crawl`      | Linked from a tested page                                                                      |
| `not-found`  | The not-found probe                                                                            |

`include`, `exclude` and `--grep` apply to routes from every source.

### Discovery for each framework

Discovery is on when you do not list `paths`. The [adapter](https://hydration.jscrate.dev/docs/adapters)
decides where routes come from:

| Adapter                | Routes                                                                                                                                                                     |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `next`                 | `app/` and `pages/`, plus the pages the build pre-rendered. Route groups, parallel routes and private folders are handled; API routes are skipped by the default `exclude` |
| `react-router`         | `react-router routes --json`                                                                                                                                               |
| `remix`                | `remix routes --json`                                                                                                                                                      |
| `astro`                | `src/pages`                                                                                                                                                                |
| `vite`, `node`, `none` | From the config: list them in `routes.paths`                                                                                                                               |

## List routes yourself

`paths` adds routes discovery cannot know about: redirects, rewrites, pages
behind a flag, or every route of a framework without discovery. An entry is a
path string, with an optional query, or an object with more options:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    paths: [
      "/",
      "/pricing?plan=team",
      // A page that is meant to answer 404
      { path: "/404-page", expectStatus: [404], ready: { selector: "#app" } },
      // A page that is meant to redirect
      { path: "/account", expectRedirect: "/login" },
      // Only in one scenario
      { path: "/admin", scenarios: ["admin"] },
    ],
  },
});
```

- `expectStatus` lists HTTP statuses that are not errors for the route.
- `expectRedirect` is the path the route should end on. Without it, a page that
  ends on another path is reported as [HP9010](https://hydration.jscrate.dev/docs/issues/hp9010).
- `ready` overrides the [`ready` options](https://hydration.jscrate.dev/docs/configuration#ready) for this
  route, for example a page that never goes quiet.
- `scenarios` limits the route to some [scenarios](https://hydration.jscrate.dev/docs/scenarios).

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `path` (required) | `string` | — | Path, e.g. `/pricing` or `/products/42?tab=reviews`. |
| `expectRedirect` | `string` | — | The final URL (path) the route must redirect to. Other redirects are reported. |
| `pattern` | `string` | the path without query | Route pattern used for grouping and fingerprints. |
| `expectStatus` | `number[]` | — | HTTP statuses that are not errors for this route (e.g. `[404]`). |
| `scenarios` | `string[]` | — | Only test in these scenarios. |
| `ready` | `ReadyConfig` | — | Per-route readiness overrides. |
| `navigateFrom` | `string` | `checks.navigation.from` | Page the navigation check starts from for this route. |

## Dynamic routes

A route like `/products/[id]` has nothing to render until it has a value. Give
it example values in `dynamic`:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    dynamic: {
      "/products/[id]": ["1", "42"],
      // Several parameters: separate the values with "/";
      // a catch-all takes the rest
      "/[lang]/docs/[...slug]": ["en/getting-started/install"],
    },
  },
});
```

One syntax works for every framework: `[id]`, `[...slug]` (catch-all) and
`[[lang]]` (optional). React Router's `:id`, `*` and `:lang?`, and Astro's
`[id]` and `[...slug]`, are converted, so `routes.dynamic` looks the same
everywhere.

For Next.js, pages the build pre-rendered with `generateStaticParams` or
`getStaticPaths` are tested without any config: up to `manifestExamples` values
(3 by default) are taken per dynamic route from the build output. Dynamic
routes that end up with no values are skipped, and the run lists them in a note.

## Query-string variants

When a page renders differently for a query string, test each variant:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    query: { "/search": ["?q=shoes", "?q=&page=2"] },
  },
});
```

## Routes from the sitemap

`sitemap: true` (or `--sitemap`) also tests the routes in your sitemap. It reads
the `Sitemap:` lines of `robots.txt` and `/sitemap.xml`; you can also give a
sitemap URL or path. Sitemap indexes are followed, and absolute URLs are moved to
the tested app, so a sitemap that names `https://example.com` still works
locally.

## Crawl links from tested pages

`crawl: true` (or `--crawl`) also tests same-origin links found on the tested
pages, to a depth of 2 and at most 50 routes. Pass `{ depth, limit }` to change
that. Crawled links are grouped under the most specific known route pattern, and
crawling never leaves the app's origin.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `depth` | `number` | 2 | Link depth from the start routes. |
| `limit` | `number` | 50 | Maximum number of crawled routes. |

## The not-found page

For Next.js, `notFound` is on: hydration-proof also loads
`/hydration-proof-not-found` and checks that your not-found page hydrates. That
URL must answer with a 404. Set `notFound: false` to skip it.

## Include and exclude routes with globs

`include` and `exclude` take globs, matched against the route's path:

- `*` matches one path segment.
- `**` matches any number of segments, and `/blog/**` also matches `/blog`.
- `exclude` defaults to `["/api/**"]`. Setting it replaces that default, so
  keep `/api/**` in your list.

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    include: ["/", "/blog/**", "/products/*"],
    exclude: ["/api/**", "/admin/**"],
  },
});
```

Scenarios have their own `include` and `exclude`, for pages only one identity
can open.

## Choose routes on the command line

```bash
# Only these routes; discovery, the sitemap and the not-found probe are off
npx hydration-proof test --route / --route /pricing

# Only routes whose path matches a regular expression
npx hydration-proof test --grep "^/blog"

# Also the sitemap, and links found on tested pages
npx hydration-proof test --sitemap --crawl

# Only the routes the files changed since main can affect
npx hydration-proof test --changed main
```

`--grep` applies to routes from every source. `--changed` reads an import graph
of your sources; see [CI](https://hydration.jscrate.dev/docs/ci) for how it picks routes.

## The route cache

Discovered routes are cached in `.hydration-proof/cache/routes.json`. The cache
key covers the Next.js build id and the modification times of the route
folders, so a new build or a changed route folder invalidates it. Sitemap and
crawled routes are never cached.

If a new page is missing, run with `--no-cache`, or set `cache: false` in the
config to always discover again:

```bash
npx hydration-proof test --no-cache
```

## How to test every route for hydration errors in a Next.js app

This config, from the package's examples, is what most Next.js apps end up
with after the first run: discovery, one real value per dynamic segment, and
the routes that are not pages.

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    discover: true,
    // Routes discovery cannot see: redirects, rewrites, anything behind a flag
    paths: ["/pricing?plan=team"],
    // One real value per dynamic segment, or the route is skipped
    dynamic: {
      "/blog/[slug]": ["hello-world"],
      "/products/[id]": ["1", "42"],
    },
    exclude: ["/api/**", "/admin/**"],
  },
});
```

## All route options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `paths` | `(string \| RouteEntry)[]` | `["/"]` when discovery is off | Routes to test. Strings are paths. |
| `dynamic` | `Record<string, string[]>` | — | Example values for dynamic segments, e.g. `{ "/products/[id]": ["1", "42"] }`. |
| `include` | `string[]` | — | Glob patterns (`*`, `**`) a route must match. |
| `exclude` | `string[]` | — | Glob patterns of routes to skip. |
| `discover` | `boolean` | `true` when `paths` is empty | Find routes from the framework (Next.js app/ and pages/, plus build manifests). |
| `query` | `Record<string, string[]>` | — | Query-string variants per route pattern, e.g. `{ "/search": ["?q=shoes", "?q=&page=2"] }`. |
| `sitemap` | `boolean \| string` | — | Read routes from the sitemap: `true` for /sitemap.xml (and robots.txt), or a sitemap URL/path. |
| `crawl` | `boolean \| CrawlConfig` | — | Follow same-origin links found on tested pages. |
| `notFound` | `boolean` | `true` for Next.js | Also test a URL that does not exist, to check the not-found page hydrates. |
| `manifestExamples` | `number` | 3 | Most example values taken per dynamic route from build manifests. |

## Related

- [Scenarios and signed-in pages](https://hydration.jscrate.dev/docs/scenarios)
- [Frameworks and adapters](https://hydration.jscrate.dev/docs/adapters): how each one discovers routes
- [Configuration reference](https://hydration.jscrate.dev/docs/configuration)
- [Split the routes across CI jobs](https://hydration.jscrate.dev/docs/ci)
- [HP9010: a page ended on an unexpected URL](https://hydration.jscrate.dev/docs/issues/hp9010)
