Prerender crawler corrupts query strings and loops forever when links carry search params
Which project does this relate to?
Start
Describe the bug
The static prerender crawler in @tanstack/start-plugin-core applies withTrailingSlash to the entire request path including the query string. withTrailingSlash from ufo, called without its respectQueryAndFragment option, appends the slash to the end of the string, so the slash lands inside the last search-param value instead of on the path.
packages/start-plugin-core/src/prerender.ts (line ~140):
const res = await requestWithRedirects(
withTrailingSlash(withBase(page.path, routerBasePath)),
// ...
)
Demonstration in isolation:
import { withTrailingSlash, withBase } from 'ufo'
withTrailingSlash(withBase('/posts?page=2', '/'))
// => "/posts?page=2/" ❌ slash appended to the query value
withTrailingSlash(withBase('/posts?page=2', '/'), true)
// => "/posts/?page=2" ✅ slash on the path, query preserved
When crawlLinks is enabled (the default), this turns into an infinite loop:
- A page renders an anchor whose href carries a search param, such as
/posts?page=2.
- The crawler requests
withTrailingSlash("/posts?page=2") => /posts?page=2/.
- The server parses
page=2/ (the trailing slash is now part of the value) and renders a page whose own links serialise page=2%2F.
extractLinks picks that up, the crawler appends another slash => page=2%2F%2F, and so on.
Every URL is unique, so the crawler's seen set never dedups. The queue grows without bound and the build hangs until it runs out of memory.
This bites any app that has a search param on a route reachable through a crawled <a href>. A common trigger is a root-level search param such as a currency or locale selector rendered in shared page chrome, so every prerendered page emits a self-link carrying it.
Your Example Website or App
Minimal reproduction: any TanStack Start app with prerender.enabled and a route that renders an <a> whose href includes a query string, such as a <Link to="." search={{ page: 2 }}>.
Steps to Reproduce the Bug or Issue
- Create a TanStack Start app with
tanstackStart({ prerender: { enabled: true } }).
- Add a search param to a route and render a
<Link> to that route with the search param set (so the prerendered HTML contains <a href="/route?param=value">).
- Run
pnpm build.
- Observe the prerender log crawling the same path with an ever-growing
%2F suffix on the param value, and the build never completing.
[prerender] Crawling: /route?param=value
[prerender] Crawling: /route?param=value%2F
[prerender] Crawling: /route?param=value%2F%2F
...
Expected behavior
The trailing slash should be applied to the path only, leaving the query string intact:
/route?param=value → /route/?param=value
The crawler should then converge and dedup normally rather than generating an unbounded stream of unique URLs.
Screenshots or Videos
N/A
Platform
- Affected package:
@tanstack/start-plugin-core, reproduced on 1.171.20 with the same code on main
- Reproduced on Linux with Node 24
Additional context
The fix is a one-line change: pass respectQueryAndFragment: true to withTrailingSlash. Happy to open a PR (with a changeset).
Prerender crawler corrupts query strings and loops forever when links carry search params
Which project does this relate to?
Start
Describe the bug
The static prerender crawler in
@tanstack/start-plugin-coreapplieswithTrailingSlashto the entire request path including the query string.withTrailingSlashfromufo, called without itsrespectQueryAndFragmentoption, appends the slash to the end of the string, so the slash lands inside the last search-param value instead of on the path.packages/start-plugin-core/src/prerender.ts(line ~140):Demonstration in isolation:
When
crawlLinksis enabled (the default), this turns into an infinite loop:/posts?page=2.withTrailingSlash("/posts?page=2")=>/posts?page=2/.page=2/(the trailing slash is now part of the value) and renders a page whose own links serialisepage=2%2F.extractLinkspicks that up, the crawler appends another slash =>page=2%2F%2F, and so on.Every URL is unique, so the crawler's
seenset never dedups. The queue grows without bound and the build hangs until it runs out of memory.This bites any app that has a search param on a route reachable through a crawled
<a href>. A common trigger is a root-level search param such as a currency or locale selector rendered in shared page chrome, so every prerendered page emits a self-link carrying it.Your Example Website or App
Minimal reproduction: any TanStack Start app with
prerender.enabledand a route that renders an<a>whose href includes a query string, such as a<Link to="." search={{ page: 2 }}>.Steps to Reproduce the Bug or Issue
tanstackStart({ prerender: { enabled: true } }).<Link>to that route with the search param set (so the prerendered HTML contains<a href="/route?param=value">).pnpm build.%2Fsuffix on the param value, and the build never completing.Expected behavior
The trailing slash should be applied to the path only, leaving the query string intact:
The crawler should then converge and dedup normally rather than generating an unbounded stream of unique URLs.
Screenshots or Videos
N/A
Platform
@tanstack/start-plugin-core, reproduced on 1.171.20 with the same code onmainAdditional context
The fix is a one-line change: pass
respectQueryAndFragment: truetowithTrailingSlash. Happy to open a PR (with a changeset).