BR-655 — Page redirects to index.txt after session ends

Status: root cause confirmed · fix implemented and verified Component: bridge-ui · Next.js 15.5.3 · output: "export" · S3 + CloudFront Investigated: 17 August 2026, against bridge-ui@5ef551b

All line numbers refer to the installed package under node_modules, not to this repository.


Verdict

In output: "export" builds, Next appends /index.txt to the URL to fetch a route's RSC payload. Three code paths can abandon that fetch and hand control back to the browser. Two strip the suffix first. The third — the catch — does not, so the browser is sent to the .txt file itself, which S3 serves as text/plain.

Anything that makes that fetch fail or be cancelled lands the user on the payload. This is a Next.js defect, fixed upstream in Next 16 and not backported.


Causes

Root cause — confirmed

A Next.js defect. In fetchServerResponse, the catch returns the RSC payload URL without stripping the /index.txt suffix that its two sibling bail-outs both strip.

next@15.5.3 · fixed in 16 · not backported (15.5.23 still affected)

Trigger conditions — what makes that branch run

Trigger Reliability
The payload fetch fails at the network layer Reproduces every time
The shared AbortController is left aborted after pagehide with no pageshow Reproduces every time
Session-expiry logout races the route guard's navigation, and the page unload cancels the fetch 2 observed, then 0 of 18

Contributing factor

Production serves no Cache-Control on any object and each release invalidates /*, so every deploy returns the whole asset graph to origin. That raises the payload-fetch failure rate exactly when people return to idle tabs — consistent with the reported clustering, though not measured.

Not causes

NGINX/Apache rules, the index.html entry point, response Content-Type, backend 401 handling, SPA fallback, deployment artifacts, and missing files were each checked and cleared. See Scope of investigation.


Proposed solutions

# Solution Status
1 Patch the defective branchpostinstall script rewriting the catch to use doMpaNavigation. Removes the cause for every route and trigger. Implemented · verified
2 CloudFront guard — redirects any .txt requested as a page to the stripped path. Independent of the client, so it also protects users still running a cached older bundle. Written · verified · needs attaching
3 Cache headers and a narrower invalidationimmutable on _next/static/, excluded from the purge. Reduces the trigger rate. Proposed
4 Upgrade to Next 16 — the defect is already fixed upstream, which retires solution 1 entirely. Proposed · not for an incident

Solutions 1 and 2 together are the complete answer: the first removes the defect, the second does not depend on the client being correct. Solutions 3 and 4 are follow-up work.


Why .txt files exist at all

bridge-ui is a Next.js App Router app built with output: "export" and trailingSlash: true, deployed as static files to S3 behind CloudFront. There is no server at runtime — every page is pre-rendered.

Client-side navigation still needs the server component tree for the destination route. With no server to ask, the export writes that tree to disk next to each page. out/ currently holds 27:

out/index.html          ← the page
out/index.txt           ← its RSC payload
out/login/index.html
out/login/index.txt
out/members/index.html
out/members/index.txt
… 27 index.txt files in total

These files are correct, expected build output. Serving them as text/plain is also correct. Neither is the bug — both are why the bug is so visible when it fires: a text/plain response renders in the viewport instead of downloading.


The defect

All of this lives in one file: node_modules/next/dist/client/components/router-reducer/fetch-server-response.js

On every client-side navigation it rewrites the URL, then fetches it (lines 96–105):

if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
    url = new URL(url);
    if (url.pathname.endsWith('/')) url.pathname += 'index.txt';
    else                            url.pathname += '.txt';
}
const res = await createFetch(url, headers, fetchPriority, abortController.signal);

url is now /login/index.txt, and it stays that way for the rest of the function. That single detail is the whole bug.

Three things can go wrong from here. Each hands the URL to the browser as a full page load:

Condition Hands over
Response isn't a flight payload, isn't 200, or has no body :123 → doMpaNavigation(responseUrl.toString()) strips
Payload is from a different build than the running bundle :140 → doMpaNavigation(res.url) strips
The fetch or the payload parse throws :151 → { flightData: url.toString() } never stripped

Both safe paths route through one helper, and the helper is where the stripping happens (line 38):

function doMpaNavigation(url) {
    return {
        flightData: urlToUrlWithoutFlightMarker(new URL(url, location.origin)).toString(),
        …
    };
}

which trims the suffix back off — route-params.js:141–149:

const length = pathname.endsWith('/index.txt') ? 10 : 4;
urlWithoutFlightParameters.pathname = pathname.slice(0, -length);

The catch is the one path that never calls it — fetch-server-response.js:151–162:

} catch (err) {
    if (!abortController.signal.aborted) {
        console.error("Failed to fetch RSC payload for " + url +
                      ". Falling back to browser navigation.", err);
    }
    return {
        flightData: url.toString(),   // ← still ".../login/index.txt"
        canonicalUrl: undefined,
        …
    };
}

This is the only way a .txt can reach the address bar

Searching the entire client runtime, the suffix is appended in exactly one function:

dist/client/…/fetch-server-response.js:99       url.pathname += 'index.txt';
dist/client/…/fetch-server-response.js:101      url.pathname += '.txt';
dist/esm/client/…/fetch-server-response.js:78   (same file, ESM copy)
dist/esm/client/…/fetch-server-response.js:80

and that function has exactly four exits:

:128  return doMpaNavigation(responseUrl.toString());   strips
:141  return doMpaNavigation(res.url);                  strips
:143  return { flightData: normalizeFlightData(…) };    success
:158  return { flightData: url.toString() };            defect

Every other reducer that can trigger a hard navigation — refresh, server-patch, HMR — calls this same function, so they all inherit the one defect rather than adding new ones. Combined with a deployment that contains no other .txt files and links to none, this is not merely a path to the symptom; it is the only one.


How that reaches the address bar

A flightData that is a string rather than a tree is Next's internal signal for "give up on the SPA and let the browser load this URL."

  1. The router sees a stringnavigate-reducer.js:222
    if (typeof flightData === 'string') {
        return handleExternalUrl(state, mutable, flightData, pendingPush);
    }
  2. It flags a full page loadnavigate-reducer.js:44
    function handleExternalUrl(state, mutable, url, pendingPush) {
        mutable.mpaNavigation = true;
        mutable.canonicalUrl = url;   // ← the .txt URL
  3. The app shell navigates thereapp-router.js:270–277
    if (pushRef.mpaNavigation) {
        if (pushRef.pendingPush) location.assign(canonicalUrl);
        else                     location.replace(canonicalUrl);
  4. S3 serves the file as writtenContent-Type: text/plain, status 200. Verified against production directly.
  5. The user reads the payload
    1:"$Sreact.fragment"
    2:I[41402,["1659","static/chunks/1659-59ad6396e1451f89.js", …

The session-expiry case goes through router.push, so it takes the location.assign branch and the broken URL is added to history as a normal entry.


What makes it throw — the session-expiry race

This is the ticket's own scenario. The app manufactures the failed fetch itself.

When the session timer fires, contexts/auth-context.tsx runs:

const logout = async () => {
  clearSessionTimer();
  try {
    clearClarityUser();
    await authService.logout();
    setUser(null);          // ← re-renders; the guard now sees "not authenticated"
    redirectToLanding();    // ← window.location.href = origin + '/'
  } catch { … same two calls … }
};

Those two lines race:

  1. The guard starts a client-side navigation. setUser(null) re-renders. MembersRouteGuard.tsx:24 sees !isAuthenticated and calls router.push('/'), which begins fetching /index.txt.
  2. The next line starts a full page load. redirectToLanding() sets window.location.href. The browser begins unloading the document.
  3. Unloading cancels the in-flight fetch. The payload request dies with TypeError: Failed to fetch — a genuine failure, not an abort, so it is not covered by the signal.aborted guard and is logged.
  4. The defective branch wins the race. The catch returns the unstripped .txt URL, and location.assign supersedes the pending navigation to /.

Reproducibility, stated honestly. This sequence was observed twice, with the console error and navigation trail captured both times. It then failed to reproduce in 18 further attempts across varied session timings, with the API healthy and unreachable, and with payload responses artificially slowed to imitate a cold CDN. The race window is evidently very narrow.

So: the sequence is real and the code plainly permits it, but it is not the confirmed production trigger — it is a demonstrated instance of the general failure.

Two secondary routes to the same branch

A failed network request. The payload fetch is a normal fetch(). A dropped connection, a CDN hiccup, a blocked request, a phone moving between cells — any of these reject with TypeError: Failed to fetch.

An aborted controller. More interesting, because it needs no failure at all. The module keeps one AbortController shared by every payload fetch — fetch-server-response.js:48–61:

let abortController = new AbortController();
window.addEventListener('pagehide', () => { abortController.abort(); });
window.addEventListener('pageshow', () => { abortController = new AbortController(); });

pagehide fires when a tab is backgrounded, put into the back/forward cache, or navigated away from. The controller is only ever restored by pageshow. Any sequence where the first event fires without the second leaves the controller permanently aborted — and from then on every navigation in that tab rejects instantly. This path also skips the console.error, so it fails silently.

All three routes converge on MembersRouteGuard.tsx:24, which calls router.push('/') once the user is no longer authenticated. Root / is why the ticket reports a bare index.txt.

The 401 handler in services/http-client.ts:82 uses window.location.href, a real page load. It is not affected. The exposure is the route guard, not the API layer.


Reproductions

All on an unmodified Next 15.5.3 and a correctly configured static host.

Reproduction Result
The ticket's own steps — real session seeded to expire in 3s, /members/ opened and left alone /kyc/error/index.txt, text/plain. Observed twice, then 0 of 18
Forced network failure — connection dropped for payload requests only, /members/ opened logged out /index.txt, with Failed to fetch RSC payload logged
Aborted controller — healthy server, pagehide dispatched, then a link clicked /invitation-only/index.txt. No server fault involved
End-to-end, app untouched — real /landing/, real link click, one payload request failed at the network layer /login/index.txt, text/plain

Which of these fire on demand

Trigger Reliability Needs
Payload fetch fails at the network layer Every time A failed request
pagehide without pageshow Every time Nothing — healthy server
Session-expiry logout race 2 observed, then 0 of 18 Winning a narrow race
Link click while offline Did not reproduce Payload not already prefetched
Navigation then immediate reload Did not reproduce

Two things follow. First, <Link> prefetching hides most of the exposure: by the time a link is clicked its payload is usually cached, so no fetch happens and nothing can fail. The vulnerable navigations are router.push() calls to routes that no visible link points at — such as /kyc/error, and / from inside the members area. Second, the rarity is a property of the trigger, not of the defect: whenever a payload fetch does fail, landing on the payload is certain, not probable.

A deterministic on-demand repro is checked in at app/br655/page.tsx — deploys to /br655/, one button. It must be run against a static export; the .txt code path is compiled out unless NODE_ENV=production and output: "export", so the bug cannot appear under next dev. That is also why it was never caught in development.

npm run build-static
npm run serve-static     # then open /br655/

Delete app/br655/page.tsx once the fix is confirmed. It is a publicly reachable page that deliberately breaks navigation.


Scope of investigation, point by point

Every line of the ticket's investigation scope was checked. All seven came back clean — the fault is in the client bundle, not in any of them.

Checked Finding
NGINX / Apache fallback Not used in production. The stack is S3 + CloudFront; bridge-ui/nginx.strict.conf is dead config.
index.html as entry point Correct.
Response headers /index.txt returns 200 text/plain. Correct — and the reason it renders rather than downloads.
Backend 401 handling Not involved. The API interceptor does a hard navigation.
SPA fallback Not applicable. Every route is pre-rendered.
Deployment artifacts No misconfiguration. The .txt files are meant to be there.
CDN / cache Not the cause. All 20 chunks referenced by production index.html resolve 200.

A missing payload was checked specifically, since it is the intuitive suspect: /no-such-route/index.txt returns 404 text/html, which is the stripping branch. Absent files cannot produce this symptom.


Why it clusters after a deploy

What was ruled out

A stale tab meeting a new build does not cause it. Two different builds were produced and one swapped in under a live tab, mirroring aws s3 cp followed by a /* invalidation. The tab navigated correctly, because the build-mismatch branch (:141) is one of the two that strips.

A limit of that test: it ran against a local server with a zero percent failure rate. It proves build mismatch is not directly causal. It cannot rule out an effect driven by request failure rate, because there were no failures available to observe.

The likely explanation

A release is when people come back to tabs they left open for hours — and a tab left open for hours is a tab whose session has expired. The deploy is not causal; it is simply the event that makes a group of people wake stale sessions at roughly the same moment.

A secondary amplifier

A release does also raise the background failure rate. The pipeline ends with create-invalidation --paths "/*", purging every object at every edge. Production serves no Cache-Control header on any object:

GET /_next/static/chunks/webpack-d10cbd12d1ba549a.js
200 · content-type: text/javascript · no cache-control

GET /login/                200 · text/html   · no cache-control
GET /index.txt             200 · text/plain  · no cache-control

Hashed filenames are immutable by construction and should be served max-age=31536000, immutable and kept out of the invalidation.


What this does not establish

To close these: search session recordings for Failed to fetch RSC payload immediately before a redirect, and ask one reporter whether the URL was bare index.txt or a nested one. The fix does not depend on the answers — every route runs through the same catch.


Upstream status

Next 16 keeps a reference to the URL from before the rewrite:

// In static export mode, we need to modify the URL to request the .txt file,
// but we should preserve the original URL for the canonical URL and error handling.
const originalUrl = url;
…
return originalUrl.toString();

Verified by diffing the published packages:

Version Status Note
15.5.3 Affected Currently in production
15.5.23 Affected Latest 15.5 backport line — fix absent
16.3.1 Fixed Uses originalUrl

The same defective code was confirmed live in the production bundle at /_next/static/chunks/1255-522eae97879766d9.js.


The fix — two layers

The fix should not depend on knowing the trigger. These two layers together cover the whole class: the first removes the defect, the second catches anything that still reaches a payload URL — including users whose browsers are still running a cached older bundle after the patch ships.

1 — Route the catch through the same helper as its siblings

doMpaNavigation already returns an identical object shape, so the correction is a true one-liner:

// fetch-server-response.js, in the catch
return doMpaNavigation(url.toString());

Shipped as scripts/patch-next-br655.mjs, wired to postinstall, which runs after npm i in the pipeline and before the build. It is idempotent, and it throws if the expected code is absent, so a future Next upgrade fails loudly rather than silently dropping the fix.

Not patch-package: installing it fails with ERESOLVE on a pre-existing peer conflict between @vitejs/plugin-react and vite@8.2.1. That conflict is unrelated to this ticket, but adding any dependency forces a re-resolve.

Both copies must be patched — Next ships dist/client/… and dist/esm/client/…, and the client build uses the ESM one.

Why a script rather than editing the file

The edit is one line. The script exists because the defective code lives in node_modules, which is not in version control and is rebuilt by npm i on every CI run.

Approach Outcome
Edit node_modules directly Wiped by the next install
Commit a .patch, apply with git apply Same shape, more brittle across line endings
Vendor the file and alias it in webpack Replaces ~170 lines instead of one, and owns a private copy of a Next internal that imports eight others
Intercept location.assign in app code Impossible — see below
Stop using router.push in the guards Partial; does not cover <Link> clicks

The last-moment interception looks attractive — catch Next's own location.assign(canonicalUrl) in our code and strip the suffix there. It does not work; Location is protected by the browser:

window.location.assign = fn;    → silently ineffective
Location.prototype.assign       → undefined (not an overridable prototype method)
…the browser navigated to /test/index.txt anyway.

Verification

Run against the ticket's own scenario — expiring session on /members/ — with only the bundle differing:

Build Navigation trail Ends on Content type
Unpatched /members/ → /kyc/error/index.txt Raw payload text/plain
Patched /members/ → /kyc/error → / Landing page text/html

The patched build recovers to the landing page — the ticket's stated Expected Result.

Trap worth recording. The first rebuild after patching produced a byte-identical chunk — same hash, still the defective code. Webpack's filesystem cache under .next/cache (360 MB) served the stale module. After clearing it the hash changed and the emitted code became p(e.toString()), the minified doMpaNavigation. CI is unaffected — the pipeline caches node, not .next — but any local verification must clear it first, or it will confirm a fix that is not in the bundle.

One behavioural note: stripping /index.txt yields /login without a trailing slash. With trailingSlash: true this was checked against production directly — CloudFront returns 302 → /login/, so it resolves correctly at the cost of one extra round trip.

2 — A CDN guard, for everything else

A CloudFront viewer-request function, checked in at infra/cloudfront-rsc-txt-guard.js. It redirects a request for a .txt to the stripped path only when the browser is loading it as a page:

var dest = headers['sec-fetch-dest'] && headers['sec-fetch-dest'].value;
if (dest && dest !== 'document') return request;   // payload fetch — untouched

A genuine payload fetch is a sub-resource and sends Sec-Fetch-Dest: empty; a browser landing on the URL sends document. For clients that omit the header, it falls back to requiring no RSC header and an Accept that asks for HTML. It also drops Next's _rsc cache-busting parameter.

Safe on this distribution because it contains zero legitimate .txt files — there is no robots.txt. That must be re-checked before reusing it elsewhere.

This file is inert until deployed. It has to be uploaded and attached to the viewer-request event on the distributions:

Environment Distribution
Production E1G87BUL2ZQ79Y
Staging E8PIFFYJ9T8RO

Guard verification

Tested by running the guard in front of the unpatched build, so the defect was still present:

Case Result Content type
Ordinary link click /login/ text/html
The deterministic trigger /login/ (was /login/index.txt) text/html
Payload URL opened directly /members// text/html

The guard alone eliminates the user-visible symptom without any client change, and does not disturb normal navigation. That is what makes the pair universal: layer one is correct, layer two does not depend on the client being correct.

3 — Cache headers, and a narrower invalidation

Independent of the defect, aimed at the trigger rate. Content-hashed assets under _next/static/ should be served Cache-Control: max-age=31536000, immutable and excluded from the release invalidation; only the HTML and payloads need purging.

4 — Upgrade to Next 16

The durable fix, and a major version bump. It makes the patch script redundant — the script's own error message says so. Not something to do under an incident.