template-compiler.ts says, at the point it builds the context:
// Create a recording context — server scripts are NOT executed at build time
// (they may depend on request data like session, DB queries, etc.)
const { context } = createRecordingContext({ … })They are executed, and whatever they produce is baked into the compiled HTML. Two builds of the same unchanged file, thirty milliseconds apart:
a timestamp placeholders=0 build1=<p>2026-08-10T12:05:07.563Z</p> build2=<p>2026-08-10T12:05:07.635Z</p>
a random id placeholders=0 build1=<p>4zlq0bm1fth</p> build2=<p>jbi2puezz6c</p><script server>
const builtAt = new Date().toISOString()
</script>
<p>{{ builtAt }}</p>Every visitor to that page in production sees the moment the build ran, forever. No placeholder is emitted, so hydrateTemplateStream has nothing to fill and the request-time execution of the same server block cannot reach the output.
What decides it
A placeholder is only created for an expression the compiler could not resolve:
server var declared in the block placeholders=0 html="<h1>built</h1>"
var never declared anywhere placeholders=1 html="<h1><!--__STX_EXPR_0__--></h1>"
value read off __stxServeContext placeholders=1 html="<h1><!--__STX_EXPR_0__--></h1>"So the rule in practice is whether the value happened to be computable at build time, not whether it is request-dependent. A page reading __stxServeContext is safe by accident — the value is undefined during the build. A page computing anything itself is frozen.
Why it is easy to miss
The failure is invisible for the majority of pages, because most server blocks compute something genuinely static — a title, a nav list, a count. Those bake correctly and the page is right. It only shows for a value that must differ per request, and then it shows as a stale value rather than a missing one, which reads as a caching problem rather than a build problem.
How I hit it
Wiring the production half of #1847. A page written the natural way:
<script server>
export async function action({ form }) {
if (!String(form.email).includes('@'))
return { errors: 'Enter a valid email address.', values: form.email }
return { redirect: '/welcome' }
}
const errors = ''
const values = ''
</script>
<form method="POST">
<input name="email" value="">
<p>{{ errors }}</p>
</form>const errors = '' is what makes the GET render clean. It also freezes both expressions to the empty string at build time, so the action runs, returns its message, merges it into the render context — and the template it merges into no longer has anywhere to put it. The redirect branch works, because that path never renders.
Removing the two initialisers makes it work, which is not a rule anyone would guess.
Asks
- Decide what the contract actually is, and make the code and the comment agree. The comment describes the behaviour I would expect; the code does something else.
- If server scripts are meant to run at build time, a page needs a way to say a value is request-dependent. If they are not,
createRecordingContextneeds to be what actually feeds the pipeline. - Either way, a page that exports
actionis request-dynamic by definition and should not have its expressions frozen. - A test that compiles the same source twice and asserts the output is identical would have caught this, and would keep catching it.
Found while working on #1847; blocks its production path for the natural authoring shape.