$npx -y skills add vercel/next.js --skill router-actHow to write end-to-end tests using createRouterAct and LinkAccordion. Use when writing or modifying tests that need to control the timing of internal Next.js requests (like prefetches) or assert on their responses. Covers the act API, fixture patterns, prefetch control via LinkA
| 1 | # Router Act Testing |
| 2 | |
| 3 | Use this skill when writing or modifying tests that involve prefetch requests, client router navigations, or the segment cache. The `createRouterAct` utility from `test/lib/router-act.ts` lets you assert on prefetch and navigation responses in an end-to-end way without coupling to the exact number of requests or the protocol details. This is why most client router-related tests use this pattern. |
| 4 | |
| 5 | ## When NOT to Use `act` |
| 6 | |
| 7 | Don't bother with `act` if you don't need to instrument the network responses — either to control their timing or to assert on what's included in them. If all you're doing is waiting for some part of the UI to appear after a navigation, regular Playwright helpers like `browser.elementById()`, `browser.elementByCss()`, and `browser.waitForElementByCss()` are sufficient. |
| 8 | |
| 9 | ## Core Principles |
| 10 | |
| 11 | 1. **Use `LinkAccordion` to control when prefetches happen.** Never let links be visible outside an `act` scope. |
| 12 | 2. **Prefer `'no-requests'`** whenever the data should be served from cache. This is the strongest assertion — it proves the cache is working. |
| 13 | 3. **Avoid retry/polling timers.** The `act` utility exists specifically to replace inherently flaky patterns like `retry()` loops or `setTimeout` waits for network activity. If you find yourself wanting to poll, you're probably not using `act` correctly. |
| 14 | 4. **Avoid the `block` feature.** It's prone to false negatives. Prefer `includes` and `'no-requests'` assertions instead. |
| 15 | |
| 16 | ## Act API |
| 17 | |
| 18 | ### Config Options |
| 19 | |
| 20 | ```typescript |
| 21 | // Assert NO router requests are made (data served from cache). |
| 22 | // Prefer this whenever possible — it's the strongest assertion. |
| 23 | await act(async () => { ... }, 'no-requests') |
| 24 | |
| 25 | // Expect at least one response containing this substring |
| 26 | await act(async () => { ... }, { includes: 'Page content' }) |
| 27 | |
| 28 | // Expect multiple responses (checked in order) |
| 29 | await act(async () => { ... }, [ |
| 30 | { includes: 'First response' }, |
| 31 | { includes: 'Second response' }, |
| 32 | ]) |
| 33 | |
| 34 | // Assert the same content appears in two separate responses |
| 35 | await act(async () => { ... }, [ |
| 36 | { includes: 'Repeated content' }, |
| 37 | { includes: 'Repeated content' }, |
| 38 | ]) |
| 39 | |
| 40 | // Expect at least one request, don't assert on content |
| 41 | await act(async () => { ... }) |
| 42 | ``` |
| 43 | |
| 44 | ### How `includes` Matching Works |
| 45 | |
| 46 | - The `includes` substring is matched against the HTTP response body. Use text content that appears literally in the rendered output (e.g. `'Dynamic content (stale time 60s)'`). |
| 47 | - Extra responses that don't match any `includes` assertion are silently ignored — you only need to assert on the responses you care about. This keeps tests decoupled from the exact number of requests the router makes. |
| 48 | - Each `includes` expectation claims exactly one response. If the same substring appears in N separate responses, provide N separate `{ includes: '...' }` entries. |
| 49 | |
| 50 | ### App Shell requests are ignored by default |
| 51 | |
| 52 | When App Shells are enabled (the default when Cache Components is on), a `prefetch` is split into two phases: an **App Shell** prefetch — the param/searchParam-independent chrome of the route (layouts, loading boundaries, static shell) — and a separate per-link/per-page data prefetch. The App Shell is conceptually part of the route, not prefetch data, so **`act` ignores App Shell requests for all assertion purposes** (they carry a `next-router-prefetch: '3'` header). |
| 53 | |
| 54 | This means you generally do **not** need to account for the extra App Shell response in your assertions. If a `Loading...` fallback now arrives in both the App Shell prefetch and the per-link prefetch, you still write a single `{ includes: 'Loading...' }` — the App Shell copy is invisible to matching. Likewise, `'no-requests'` still passes even if an App Shell prefetch fires, and `block: 'reject'` won't match content that appears only in the App Shell. |
| 55 | |
| 56 | App Shell requests are still intercepted, fulfilled, and awaited (so the shell is cached and no requests are left in flight) — they just don't participate in `includes` matching, `no-requests`, `block: 'reject'`, or the "at least one request" check. An App Shell response that returns an error status (4xx/5xx) still fails the test. |
| 57 | |
| 58 | To assert on App Shell responses directly — for tests specifically about App Shell behavior — opt in at the `act` instance level: |
| 59 | |
| 60 | ```typescript |
| 61 | const act = createRouterAct(page, { includeAppShellRequests: true }) |
| 62 | ``` |
| 63 | |
| 64 | With this option, App Shell requests are treated like any other router request. Prefer expressing App Shell behavior through observable outcomes (e.g. an instant navigation rendering the cached she |