Script Types
On this page 18
Understanding script types is the single most important concept in stx. There are exactly two script types, and they run in completely different environments.
The Two Script Types
| Tag | Runs on | When | Output |
|---|---|---|---|
<script server> | Server (Bun) | Build time (SSG) or per-request (SSR) | Stripped from HTML -- never reaches browser |
<script client> | Browser | Page load | Shipped as-is in the final HTML |
A bare <script> tag (without server or client) is treated as client-side. We recommend always being explicit with <script client> for clarity.
TypeScript is the default. You never need lang="ts" on any script tag.
<script server> -- Server-Side Execution
Server scripts run in Bun on the server. They make variables available to template expressions ({{ }}). This is where you fetch data, query databases, set SEO metadata, and define page configuration.
<scriptserver
const response = await fetch('https://api.example.com/posts')
const posts = await response.json()
const title = 'Our Blog'
const description = 'Latest posts from the team'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
})
definePageMeta({
layout: 'marketing',
})
</script>
<h1>{{ title }}</h1>
<ul>
@foreach(posts as post)
<li>{{ post.title }}</li>
@endforeach
</ul>
What You Can Do in <script server>
- Data fetching:
fetch(), database queries, file reads - SEO:
useSeoMeta(),definePageMeta() - The response status:
setResponseStatus(),notFound(),setResponseHeader(), when the page is the only thing that knows it - Variable declarations: All
const,let,var, andfunctiondeclarations are automatically available to{{ }}expressions - Use secrets: API keys, database credentials -- this code never reaches the browser
- Import server modules: Bun APIs, Node.js builtins, server-only npm packages
Setting the status from the page
definePageMeta({ status: 404 }) is read out of the source before anything
runs, which is right for a page that is always an error page. A page addressed
by a dynamic segment -- a repository, a user, an order -- cannot know whether
the thing exists until it has looked, so it needs to say so afterwards:
<!-- views/[owner]/[repository]/index.stx -->
<scriptserver
const repository = await findRepository(params.owner, params.repository)
if (!repository)
setResponseStatus(404)
</script>
@if (repository)
<h1>{{ repository.name }}</h1>
@else
<h1>No such repository</h1>
@endif
Without it the page renders "no such repository" under a 200, which tells a crawler, a cache and an uptime check that the page is fine.
notFound() is the same call under the name the case usually has, so the
missing-record branch does not have to spell a number out every time:
<scriptserver
const repository = await findRepository(params.owner, params.repository)
if (!repository)
notFound()
</script>
It takes an optional status, so the neighbouring cases read the same way --
notFound(410) for a record deliberately removed rather than missing. Anything
below 400 is not what anyone means by "not found" and falls back to 404.
setResponseHeader(name, value) is the other half, and the pair is not
optional: a page that works out mid-render that a handle has moved can say 301
and, without it, cannot say where to.
<scriptserver
const moved = await findRedirect(params.slug)
if (moved) {
setResponseStatus(301)
setResponseHeader('Location', moved.target)
}
</script>
The last call wins, so a page can decide late. A status outside the HTTP range is ignored rather than thrown: it is not worth failing a page that has already rendered. The render cache carries the status with the HTML, so a cached page answers what the first render did.
All three work on every path that renders a view -- buddy dev and the
production serve, stx's own serve(), stx dev, and a precompiled build,
where the server block is re-run per request.
@status -- the same decision, in the template
When the branch is already there in the markup, the status usually belongs beside it rather than re-derived from a flag at the top of the server block:
<scriptserver
const feature = featureBySlug(params.slug)
</script>
@if (!feature)
@status(404)
<h1>No feature by that name</h1>
@else
<h1>{{ feature.title }}</h1>
@endif
@status is evaluated after the conditionals, so a @status in a branch that
lost never fires. The argument can be a literal or any expression the template
can evaluate -- @status(feature ? 200 : 404).
One limitation, and it is loud rather than silent: stx build precompiles
templates with no request, so the branch a @status sits in was chosen by a
build-time guess and baking that would hand every visitor the same answer. The
directive is skipped there with a warning naming notFound(), which the
production server re-runs per request. Use the <script server> spelling on
pages you precompile.
What You Cannot Do in <script server>
- Access
document,window,localStorage, or any browser API - Use signals (
state(),derived(),effect()) - Attach event handlers
- Call
onMount()or any lifecycle hook
The export Keyword Is Optional
All variable declarations in <script server> are automatically made available to the template. You do not need to export anything:
<scriptserver
// Both of these work identically:
const title = 'Hello' // auto-exported
export const subtitle = 'World' // explicitly exported
function greet(name) { // auto-exported
return `Hello, ${name}!`
}
</script>
<h1>{{ title }}</h1>
<p>{{ greet('Alice') }}</p>
Credentials: prefix them with __
A <script client> block can read server values directly, and stx does that by
serialising the value into the page — so a server binding your client code
references ships in the HTML response body, visible in view-source.
Prefix anything holding a credential with __. Those are never sent, whatever
the client does with the name:
<scriptserver
const __token = cookies.session // never leaves the server
const user = await getUser(__token) // safe to reference from the client
</script>
stx warns at build time when it publishes a binding whose name looks like a credential. See Security.
<script client> -- Browser Execution
Client scripts run in the browser after the page loads. This is where you create reactive state, attach event handlers, run lifecycle hooks, and build interactive features.
<scriptclient
const count = state(0)
const doubled = derived(() => count() * 2)
function increment() {
count.set(count() + 1)
}
onMount(() => {
console.log('Page mounted!')
})
</script>
<buttonclick="">
Count: <spanx-text=""></span>
</button>
<p>Doubled: <spanx-text=""></span></p>
What You Can Do in <script client>
A top-level let controller assigned in onMount stays live in template
handlers such as @click="controller?.save()", including when its setup imports
TypeScript modules. Use state for values whose changes should trigger a visual
update; an ordinary let provides a live binding without scheduling renders.
- Signals:
state(),derived(),effect() - Lifecycle hooks:
onMount(),onDestroy() - DOM manipulation:
useRef(),document.querySelector()(preferuseRef) - Event handlers: Define functions called by
@click,@submit, etc. - Composables:
useHead(),useSeoMeta(),useAuth(), custom composables - Navigation:
navigate('/path')for SPA transitions - Stores:
useStore('name')to access shared state
What You Cannot Do in <script client>
- Access server-side secrets (they are stripped at build time)
- Run database queries
- Use Bun or Node.js APIs
Both Scripts in One Page
A page can (and often should) have both script types. The server script handles data fetching and SEO, while the client script handles interactivity.
<scriptserver
const response = await fetch('https://api.example.com/products')
const products = await response.json()
useSeoMeta({
title: 'Product Catalog',
description: `Browse our ${products.length} products`,
})
</script>
<scriptclient
const search = state('')
const sortBy = state('name')
function handleSearch(term) {
search.set(term)
// Client-side filtering happens via :for and reactive bindings
}
onMount(() => {
// Focus the search input on page load
document.getElementById('search-input')?.focus()
})
</script>
<h1>Product Catalog</h1>
<input
id="search-input"
type="text"
placeholder="Search products..."
x-model="search"
/>
<div>
@foreach(products as product)
<divclass="">
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<span>${{ product.price }}</span>
</div>
@endforeach
</div>
SSG vs SSR: When Server Scripts Run
The ssr config flag controls when <script server> runs, not whether it runs.
| Mode | <script server> Runs | Result |
|---|---|---|
SSG (ssr: false, default) | Once at bun run build | Static HTML in dist/ |
SSR (ssr: true) | Per request at runtime | Dynamic HTML from .output/ server |
| Dev mode | Per request (always) | Dynamic for fast iteration |
In SSG mode, the server script runs once during the build. The output is baked into static HTML. This means:
- Data is frozen at build time
- Great for marketing pages, docs, blogs
- Cannot use request-specific data (cookies, headers)
In SSR mode, the server script runs on every request:
- Data is always fresh
- Can access request context
- Requires a running Bun server
Security: Server Code Never Reaches the Browser
Everything inside <script server> is executed on the server and then completely stripped from the HTML output. Only the template expressions' rendered values appear in the final page.
<scriptserver
// This API key NEVER appears in the browser
const API_KEY = process.env.SECRET_API_KEY
const data = await fetch(`https://api.example.com/data?key=${API_KEY}`)
const results = await data.json()
</script>
<!-- Only the rendered values appear in the HTML -->
<div>{{ results.summary }}</div>
The browser receives:
<div>The actual summary text from the API</div>
No trace of the API key, the fetch call, or the server script tag.
Common Patterns
Marketing Page with SEO
<scriptserver
const page = await fetch('https://cms.example.com/api/homepage').then(r => r.json())
useSeoMeta({
title: page.seoTitle,
description: page.seoDescription,
ogImage: page.ogImage,
})
</script>
<scriptclient
const menuOpen = state(false)
function toggleMenu() {
menuOpen.set(!menuOpen())
}
</script>
<nav>
<buttonclick=""class="">
Menu
</button>
<divshow=""class="">
@foreach(page.navItems as item)
<ax-href="">{{ item.label }}</a>
@endforeach
</div>
</nav>
<main>
<h1>{{ page.headline }}</h1>
<p>{{ page.subheadline }}</p>
</main>
Dashboard with Client-Side Data Fetching
<scriptserver
useSeoMeta({ title: 'Dashboard' })
definePageMeta({ layout: 'app', middleware: 'auth' })
</script>
<scriptclient
const stats = state(null)
const loading = state(true)
const error = state(null)
onMount(async () => {
try {
const res = await fetch('/api/dashboard/stats')
stats.set(await res.json())
} catch (e) {
error.set(e.message)
} finally {
loading.set(false)
}
})
</script>
<h1>Dashboard</h1>
<divshow="">Loading</div>
<divshow=""class=""x-text=""></div>
<divshow="">
<divclass="">
<divclass="">
<spanx-text=""></span>
<label>Total Users</label>
</div>
<divclass="">
<spanx-text=""></span>
<label>Revenue</label>
</div>
</div>
</div>
Warnings
Do not use browser APIs in
<script server>. Code likelocalStorage.getItem()ordocument.querySelector()will throw a runtime error during build or SSR because those APIs do not exist in Bun.
Do not put secrets in
<script client>. Client scripts are shipped verbatim to the browser. API keys, database passwords, and any sensitive values must go in<script server>or environment variables accessed only on the server.
Do not use
state()in<script server>. Signals are a client-side reactivity primitive. They have no meaning on the server. Use plain variables in server scripts.
Bare
<script>tags run on the client, not the server. If you write<script>without theserverattribute, it runs in the browser. This is by design and enforced across all code paths. Always use<script server>when you need server execution.