Skip to content

Prerender crawler corrupts query strings and loops forever when links carry search params #7837

Description

@cjsewell

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:

  1. A page renders an anchor whose href carries a search param, such as /posts?page=2.
  2. The crawler requests withTrailingSlash("/posts?page=2") => /posts?page=2/.
  3. 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.
  4. 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

  1. Create a TanStack Start app with tanstackStart({ prerender: { enabled: true } }).
  2. 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">).
  3. Run pnpm build.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions