Skip to content

Rolling main documentation

Built from commit f80b604a992ba1144e6c45229a8165c81e52dae0. Content may describe unreleased behavior.

RSC Hydration URL Rewriting ​

This guide explains how Trusted Server rewrites URLs inside Next.js React Server Components (RSC) payloads for App Router sites.

Overview ​

Next.js App Router streams RSC data either inline in HTML via self.__next_f.push(...) or as text/x-component responses. Those payloads can contain absolute URLs that must be rewritten to the publisher's first-party domain.

Why This Is Tricky ​

  • Flight payloads mix JSON rows and length-delimited "T" chunks.
  • Changing bytes inside a "T" chunk requires recalculating its declared length.
  • Payloads can be split across multiple script tags during streaming.

Trusted Server Approach ​

Trusted Server uses a two-phase pipeline:

  1. Streaming HTML rewrite for standard attributes and self-contained payloads.
  2. Post-processing for cross-script payloads that span multiple chunks.

Where This Runs ​

  • HTML responses: streaming rewrite with optional post-processing.
  • RSC Flight responses (text/x-component): direct Flight parsing and rewrite.

Internals ​

Background: How Next.js Delivers RSC Data ​

Next.js App Router streams RSC data using a "Flight" protocol. On initial page loads, the payload is embedded in HTML via inline <script> tags that call self.__next_f.push():

html
<script>
  self.__next_f.push([0])
</script>
<script>
  self.__next_f.push([1, '0:[[...RSC content...]]'])
</script>
<script>
  self.__next_f.push([1, '1a:T29,{"url":"https://origin.example.com"}'])
</script>

For client-side navigations, Next.js fetches Flight directly (no <script> wrapper) and expects content-type: text/x-component. The response body is the same row format, but without JSON/JS-string escaping.

RSC Flight Protocol Format ​

The Flight stream is framed as rows. Most rows end with \n, but T rows are length-delimited and do not end with a newline.

Record TypeFormatExample
T-chunk (text)ID:T<hex_length>,<content>1a:T29,{"url":"..."}
JSON arrayID:[...]0:[["page",null,...]]
JSON objectID:{...}5:{"name":"value"}
Module importID:I[...]2:I["chunk-id",...]
Head linkID:HL[...]3:HL["/_next/static/..."]
ReferenceID:$ref4:$L5
StringID:"..."6:"hello"
NullID:null7:null

Authoritative Parsing Rules ​

The React client parser frames the stream as:

  1. Read hex ID until :
  2. Read one byte to determine framing:
    • T: read hex length until ,, then read exactly N raw bytes (no newline)
    • A-Z: read until \n
    • anything else ([, {, ", t, f, n, digits...): treat as JSON row content and read until \n

The key implication: changing bytes inside a T row requires updating its hex length prefix.

The Critical T-Chunk Format ​

T-chunks include an explicit byte length:

1a:T29,{"url":"https://origin.example.com/path"}
|  | |  \- Content (exactly 41 unescaped bytes)
|  | \- Comma separator
|  \- Length in hex (0x29 = 41 bytes)
\- Chunk ID

The hex length is the unescaped byte count. Because the RSC content is embedded in a JavaScript string, escape sequences must be counted correctly.

Escape SequenceString CharsUnescaped Bytes
\\n21
\\r21
\\t21
\\\\21
\\\"21
\\xHH41
\\uHHHH61-3 (UTF-8 bytes)
\\uD800\\uDC00124 (surrogate pair)

T-Chunks Can Span Multiple Push Scripts ​

Next.js streams RSC data, so a T-chunk header can appear in one <script> tag while its content continues in later tags:

html
<!-- Script 10: T-chunk header only -->
<script>
  self.__next_f.push([1, '11:null\\n1a:T928,'])
</script>

<!-- Script 11: T-chunk content (2344 unescaped bytes) -->
<script>
  self.__next_f.push([1, '...2344 bytes of actual content...'])
</script>

This is why URL rewriting must be T-row aware and cross-script aware.

Two-Phase Processing ​

Trusted Server uses a two-phase approach for App Router payloads:

  1. Streaming HTML processing:
    • Rewrite standard HTML attributes (href, src, srcset, etc.)
    • Run integration script rewriters for self-contained payloads
    • Capture complete __next_f.push([1,"..."]) payload strings into placeholders for post-processing
  2. HTML post-processing:
    • If placeholders were captured, rewrite the recorded payloads and replace them without re-parsing HTML
    • If payloads were fragmented, re-parse the HTML to find the push payloads and rewrite them in place

Marker-Based Cross-Script Processing ​

  1. Combine all payload strings using a marker delimiter that cannot appear in valid JSON/RSC content (\x00SPLIT\x00).
  2. Scan the combined stream for ID:T<hex_length>, headers and consume exactly N unescaped bytes to find each T-chunk boundary, skipping markers.
  3. Rewrite URLs and recalculate unescaped byte lengths.
  4. Split the combined content back on the marker to recover per-script payloads.

Byte Length Calculation Algorithm ​

Trusted Server accounts for JavaScript escape sequences and UTF-8 byte counts:

  • Shared escape sequence iterator handles standard JS escapes (including \\n, \\r, \\t, \\b, \\f, \\v, \\', \\\", \\\\, \\/, \\xHH, \\uHHHH, and surrogate pairs).
  • Counts unescaped bytes for each T-chunk.
  • Consumes exactly N unescaped bytes to locate chunk boundaries.
  • Enforces safety limits (skip rewrite on unrealistic sizes).

URL Rewriting Patterns ​

The rewrite logic handles multiple URL formats in RSC content:

PatternExampleIn RSC String
Full HTTPShttps://host/pathhttps://host/path
Full HTTPhttp://host/pathhttp://host/path
Protocol-relative//host/path//host/path
Bare host (boundary)origin.example.com/pathorigin.example.com/path

Implementation References ​

The current implementation lives in:

  • crates/trusted-server-core/src/integrations/nextjs/rsc.rs
  • crates/trusted-server-core/src/rsc_flight.rs

Released under the Apache License 2.0.