Repository navigation
Regression from #1135: App Router pages with GTM URLs in RSC data fail to serve #1206
Description
Description
Since #1135 (commit a4e01eb, merged 2026-09-24), Trusted Server can no longer serve some Next.js App Router pages. The trigger is a page whose React Server Components data (the self.__next_f.push(...) flight scripts) contains a googletagmanager.com or google-analytics.com URL, on a deployment that enables both [integrations.nextjs] and [integrations.google_tag_manager]. The HTML transform fails partway through the document:
- Fastly: the client gets
200 OKand the headers, then the body stream is abandoned. With a small page, no body bytes arrive at all. - Axum:
502 Bad Gatewaywith the bodyAn internal error occurred. Cloudflare and Spin use the same buffered path and the same error mapping, so by code reading they return the same 502. I did not run those two.
Loading gtag with next/script is enough to trigger it. Take this Next.js 14.2.33 App Router root layout:
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://www.googletagmanager.com/gtag/js?id=G-TEST" strategy="afterInteractive" />
</body>
</html>
);
}next build serializes the client component's props into the page's flight data:
self.__next_f.push([1,"…[\"$\",\"$L5\",null,{\"src\":\"https://www.googletagmanager.com/gtag/js?id=G-TEST\",\"strategy\":\"afterInteractive\"}]…"])
Served through Trusted Server at a4e01eb with both integrations enabled, that page comes back as 200 OK with an empty, aborted body on Fastly (Viceroy) and as a 502 on Axum. The same happens in two other cases:
- A layout that passes the standard GTM snippet to
next/scriptas inline children. - The repository's own App Router fixture
crates/trusted-server-core/src/html_processor.test.html. Its flight data carrieshttps://www.googletagmanager.com/ns.html?id=…for a<noscript>iframe.
The same inputs work at 4c6d26a, the commit before #1135: they are served completely, with both the Next.js and the GTM rewrite applied. At a4e01eb they also work when only one of the two integrations is enabled. CI did not catch this. The App Router fixture test in google_tag_manager.rs (test_html_processing_with_fixture) enables GTM without Next.js. The only core tests that enable both integrations (fragmented_next_data_survives_with_gtm_enabled and fragmented_next_data_with_trailing_g_survives_gtm) use __NEXT_DATA__ pages with no GTM URL in them.
Steps to reproduce
Unit tests (repository only). Add these tests to mod tests in crates/trusted-server-core/src/integrations/google_tag_manager.rs, next to test_html_processing_with_fixture. The module already has make_settings, config_from_settings, create_html_processor, IntegrationRegistry and the streaming pipeline types in scope.
/// An App Router flight script that mentions a GTM URL must still be
/// processed, and the Next.js rewrite inside it must survive.
#[test]
fn rsc_flight_script_containing_gtm_url_is_processed() {
use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
use std::io::Cursor;
let mut settings = make_settings();
settings
.integrations
.insert_config(
"google_tag_manager",
&serde_json::json!({ "enabled": true, "container_id": "GTM-ABC123" }),
)
.expect("should update gtm config");
settings
.integrations
.insert_config(
"nextjs",
&serde_json::json!({ "enabled": true, "rewrite_attributes": ["href", "link", "url"] }),
)
.expect("should update nextjs config");
let registry = IntegrationRegistry::with_plan(
&settings,
Arc::new(
crate::auction::compile_auction_plan(&settings)
.expect("should compile auction plan"),
),
)
.expect("should create registry");
let html = r#"<html><body><script>self.__next_f.push([1,"{\"href\":\"https://origin.example.com/app\",\"gtm\":\"https://www.googletagmanager.com/gtm.js?id=GTM-ABC123\"}"])</script></body></html>"#;
let processor = create_html_processor(config_from_settings(&settings, ®istry));
let mut pipeline = StreamingPipeline::new(
PipelineConfig {
input_compression: Compression::None,
output_compression: Compression::None,
chunk_size: 8192,
},
processor,
);
let mut output = Vec::new();
pipeline
.process(Cursor::new(html.as_bytes()), &mut output)
.expect("should process an App Router page that mentions GTM");
let processed = String::from_utf8(output).expect("should produce UTF-8");
assert!(
processed.contains(r#"\"href\":\"https://test.example.com/app\""#),
"should keep the Next.js RSC rewrite: {processed}"
);
}
/// The repository's App Router fixture carries a GTM URL inside Next.js
/// flight data. With both integrations enabled it must still be processed.
#[test]
fn html_fixture_with_gtm_and_nextjs_is_processed() {
use std::io::Cursor;
let mut settings = make_settings();
settings
.integrations
.insert_config(
"google_tag_manager",
&serde_json::json!({ "enabled": true, "container_id": "GTM-522ZT3X6" }),
)
.expect("should update gtm config");
settings
.integrations
.insert_config(
"nextjs",
&serde_json::json!({ "enabled": true, "rewrite_attributes": ["href", "link", "url"] }),
)
.expect("should update nextjs config");
let registry = IntegrationRegistry::with_plan(
&settings,
Arc::new(
crate::auction::compile_auction_plan(&settings)
.expect("should compile auction plan"),
),
)
.expect("should create registry");
let processor = create_html_processor(config_from_settings(&settings, ®istry));
let mut pipeline = StreamingPipeline::new(
PipelineConfig {
input_compression: Compression::None,
output_compression: Compression::None,
chunk_size: 8192,
},
processor,
);
let html_content = include_str!("../html_processor.test.html");
let mut output = Vec::new();
pipeline
.process(Cursor::new(html_content.as_bytes()), &mut output)
.expect("should process the App Router fixture with GTM and Next.js enabled");
let processed = String::from_utf8(output).expect("should produce UTF-8");
assert!(
processed.contains("/integrations/google_tag_manager/gtm.js?id=GTM-522ZT3X6"),
"should still rewrite the GTM preload link"
);
}cargo test -p trusted-server-core --target <host-triple> --lib -- \
rsc_flight_script_containing_gtm_url_is_processed html_fixture_with_gtm_and_nextjs_is_processedObserved at a4e01eb, both tests fail:
---- integrations::google_tag_manager::tests::rsc_flight_script_containing_gtm_url_is_processed stdout ----
should process an App Router page that mentions GTM: Proxy error: Failed to process final chunk
╰─▶ Next.js RSC captured payload was not present in parser output
---- integrations::google_tag_manager::tests::html_fixture_with_gtm_and_nextjs_is_processed stdout ----
should process the App Router fixture with GTM and Next.js enabled: Proxy error: Failed to process chunk
╰─▶ Next.js RSC output contains an unknown generated placeholder
Both tests pass at 4c6d26a.
End to end on the Fastly adapter (Viceroy). This uses the integration-test config generator.
- Build a Next.js 14.2.33 app with the layout above and
src/app/page.tsxcontainingexport default function Page() { return <a href="https://origin.example.com/reviews">Reviews</a>; }. Runnext build, then copy.next/server/app/index.htmltoorigin/gtag.html. cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1- Copy
crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.tomltoapp.toml. Setenabled = trueunder[integrations.nextjs]and[integrations.google_tag_manager], and delete the GTMupstream_urlline. - Build
generate-viceroy-config(cargo build --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target <host-triple> --bin generate-viceroy-config) and run it with--template crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml --app-config app.toml --output viceroy.toml --origin-url http://127.0.0.1:8899. - In
origin/, runpython3 -m http.server 8899 --bind 127.0.0.1. viceroy target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm -C viceroy.toml --addr 127.0.0.1:7801curl -sS -D - -o out.html -H 'Accept: text/html' -H 'Sec-Fetch-Dest: document' -H 'Sec-Fetch-Mode: navigate' http://127.0.0.1:7801/gtag.html
Observed with a4e01eb (Viceroy 0.21.0):
HTTP/1.1 200 OK
content-type: text/html
...
transfer-encoding: chunked
curl: (18) transfer closed with outstanding read data remaining
out.html stays empty. Viceroy logs EdgeZero streaming failed: Proxy error: streaming platform response body failed: Proxy error: Failed to process chunk … Next.js RSC output contains an unknown generated placeholder.
The same harness with other pages, configurations and builds:
| Build and integrations | gtag via next/script src |
GTM snippet as next/script children |
html_processor.test.html (554,355 bytes) |
|---|---|---|---|
| a4e01eb, GTM + Next.js | 200, 0 body bytes, aborted | 200, 0 bytes, aborted | 200, 157,861 bytes, aborted |
| a4e01eb, Next.js only | 200, 3,853 bytes, complete | 200, 4,071 bytes, complete | 200, 554,500 bytes, complete |
| a4e01eb, GTM only | 200, 3,854 bytes, complete | 200, 4,071 bytes, complete | 200, 554,503 bytes, complete |
| 4c6d26a, GTM + Next.js | 200, 3,854 bytes, complete | 200, 4,071 bytes, complete | 200, 554,503 bytes, complete |
At 4c6d26a the flight data came out with GTM's rewrite of the gtag URL (/integrations/google_tag_manager/gtag/js?id=G-TEST). When I ran the same page through the core pipeline with origin.example.com as the origin host, the Next.js rewrite of the page's link was applied as well.
Axum. I served the same three pages from a stub upstream through the Axum router (TrustedServerApp::routes_with_settings_and_services), which is what the Axum dev server runs, at a4e01eb. With GTM and Next.js enabled, all three returned 502 Bad Gateway, content-type: text/plain; charset=utf-8, body An internal error occurred\n. With only Next.js enabled, all three returned 200 with the processed page.
Expected behavior
With both integrations enabled, an App Router page whose flight data mentions GTM or GA is served completely with status 200, and the Next.js rewrite of its flight data is applied.
Actual behavior
- The HTML transform returns an error for the document.
- On Fastly the response has already been committed as
200 OK, so the client receives a truncated or empty body. - On Axum the client receives
502 Bad GatewaywithAn internal error occurred.
Root cause
Three script rewriters match a flight script: Next.js's script#__NEXT_DATA__ (which does not apply here), Next.js's NextJsRscPlaceholderRewriter on script, and GTM on script. create_html_processor gives each its own lol_html text handler that applies its action directly to the shared chunk (html_processor.rs:664-694). In lol_html 2.9.0 every handler reads the original chunk text, and for each chunk the last replace() wins. GTM is registered after Next.js (integrations/mod.rs:302-305 and 326-329).
#1135 changed how the placeholder rewriter works:
- It now captures each flight payload during the parse and replaces it with a generated placeholder (
nextjs/rsc_placeholders.rs:96-106). - A post-parser stream processor must then find those placeholders in the parser output, in order. If a placeholder is missing or out of order, it returns an error for the whole document (
nextjs/rsc_stream.rs:467-470,Next.js RSC output contains an unknown generated placeholder, and:521-524,Next.js RSC captured payload was not present in parser output). - Before Make body hold parser-aware and stream Next.js processing #1135 the placeholder rewriter returned
Keepon every intermediate fragment and relied on a post-processor that re-parsed the final HTML. See the comment atrsc_placeholders.rs:57-62in 4c6d26a.
GTM buffers every script that contains its hosts and, on the last fragment, replaces it with Self::rewrite_gtm_urls(text) built from the original text (google_tag_manager.rs:1045-1077). Because GTM runs last, its replacement discards the placeholder text. The stream processor then finds a placeholder missing and fails.
How the error reaches the client:
- Fastly: the adapter calls
publisher_response_into_streaming_response(trusted-server-adapter-fastly/src/app.rs:906-915). That function returns the origin status and headers with a lazy body. The processing error surfaces inside that body stream throughpublisher_stream_error(publisher.rs:275-277,2707-2727).send_edgezero_responsehas already calledstream_to_client(). On the error it logsEdgeZero streaming failedand drops theStreamingBodywithoutfinish()(trusted-server-adapter-fastly/src/main.rs:346-363). The fastly 0.12.1 crate documents: "A streaming body will be automatically aborted if it goes out of scope without callingfinish()." - Axum:
buffer_publisher_response_asyncreturnsTrustedServerError::Proxy.execute_handlermaps it throughhttp_error(trusted-server-adapter-axum/src/app.rs:118-130,170-172), andIntoHttpResponsemapsProxyto502 Bad Gatewaywith the generic messageAn internal error occurred(error.rs:128,147). - Cloudflare and Spin: they call the same
buffer_publisher_response_asyncand have identicalhttp_errorfunctions (trusted-server-adapter-cloudflare/src/app.rs:256,274-286;trusted-server-adapter-spin/src/app.rs:173,455-467).
Impact
- Affected deployments: those that enable both
[integrations.nextjs](off by default) and[integrations.google_tag_manager]and serve App Router pages. The failure needs a GTM or GA URL inside flight data. Verified ways to get one: anext/scripttag with a GTM or gtagsrc, the GTM snippet passed tonext/scriptas children, and a server-rendered<noscript>GTM iframe (the repository fixture). A component that builds the URL in the browser from an ID alone should not put the host into flight data, but I did not test one. - What visitors get: on Fastly, a blank page or a page cut off where the failing chunk started, because the transfer is aborted after
200 OK. On the other adapters, a 502 error page. Monitoring that counts status codes sees 200 on Fastly. - Pages Router: pages that use
__NEXT_DATA__do not hit this error. They have separate problems, tracked in GTM script rewriter clobbers Next.js __NEXT_DATA__ rewrites and can truncate it #1208 and Regression from #1135: __NEXT_DATA__ is corrupted when script text splits after_#1207. - Likelihood: every page I tried with a GTM or GA URL in its flight data failed at the production chunk size. The synthetic single-script test does not fail with 32-byte chunks, so the outcome depends on where fragment boundaries fall. The common case fails, though.
Proposed fix
Compose script rewriters in core instead of letting lol_html merge independent handlers. This is the change proposed in #1208: lol_html still matches selectors, and one text!("script") handler pipes each chunk through the matching rewriters in registration order and applies a single mutation. GTM then works on the placeholder rewriter's output, so the placeholders survive.
I prototyped it in a scratch copy of a4e01eb:
- Both tests above pass.
- The three pages in the table are served completely under Viceroy with both integrations enabled (200, 3,854, 4,071 and 554,503 bytes).
- Every existing test in a native
cargo test -p trusted-server-core --librun passes.
One behavior depends on order. In today's order (Next.js first), GTM sees placeholders, so a GTM URL inside flight data stays third-party: the prototype left {"src":"https://www.googletagmanager.com/gtag/js?id=G-TEST"} unchanged. GTM's attribute rewriter still rewrote the <link rel="preload"> Next.js emits for that script. Before #1135 the flight URL was rewritten. Running GTM's script rewriter ahead of Next.js's restores that. The prototype did so on the real page, together with the GTM buffering fix described in #1208.
Reordering alone, without composition, is not enough. It avoided the error on the real page, but GTM's rewrite of the flight URL was lost, and the two rewriters would still act on the same fragments independently.
A defensive follow-up, which I have not tested: when a captured placeholder is missing, the RSC stream processor could release the parser output unchanged and log a warning instead of failing the whole document. Any other script rewriter could otherwise trigger the same failure.
Done when
-
rsc_flight_script_containing_gtm_url_is_processedandhtml_fixture_with_gtm_and_nextjs_is_processedpass. - An adapter-level test serves an App Router page with a GTM URL in its flight data, with GTM and Next.js enabled, and gets 200 with complete HTML.
- Whether GTM URLs inside flight data are rewritten is decided and pinned by a test.
- Existing Next.js streaming and GTM tests still pass.
Affected area
HTML processing / JS injection
Version
main at a4e01eb
Related
- GTM script rewriter clobbers Next.js __NEXT_DATA__ rewrites and can truncate it #1208: the same missing composition on Pages Router
__NEXT_DATA__. Its proposed fix also resolves this issue. - Regression from #1135: __NEXT_DATA__ is corrupted when script text splits after
_#1207: the other script rewriter regression from Make body hold parser-aware and stream Next.js processing #1135, with the same fix. - Make body hold parser-aware and stream Next.js processing #1135: introduced the per-fragment RSC placeholders and the stream processor that rejects missing placeholders.
- Add Google Tag Manager first-party integration #262: added GTM's
scriptselector rewriter. - Phase 3: Make script rewriters fragment-safe for full streaming #584 (closed): accepted "last
text.replace()wins" for overlapping script rewriters.
Activity