Server-Side Rendering
On this page 32
STX provides built-in support for server-side rendering (SSR) with streaming capabilities.
Overview
Server-side rendering in STX:
- Executes
<script server>blocks on the server - Renders templates to HTML before sending to the client
- Supports async data fetching
- Enables streaming for better performance
Basic SSR Setup
Server Script
Use <script server> for server-only code:
<scriptserver
// Runs only on the server
const users = await db.query('SELECT * FROM users')
const config = process.env.DATABASE_URL // Safe to use
</script>
<template
<ul>
@foreach (users as user)
<li>{{ user.name }}</li>
@endforeach
</ul>
</template>
Client Script
Use <script client> for browser-only code:
<scriptclient
// Runs only in the browser
document.addEventListener('click', (e) => {
console.log('Clicked:', e.target)
})
</script>
Shared Script
Use <script> for code that runs in both environments:
<script
// Runs on server AND client
export function formatPrice(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
}
</script>
Data Fetching
Async Data Loading
<scriptserver
// Async operations are fully supported
const [products, categories] = await Promise.all([
fetch('https://api.example.com/products').then(r => r.json()),
fetch('https://api.example.com/categories').then(r => r.json()),
])
</script>
<template
<nav>
@foreach (categories as category)
<ahref="">{{ category.name }}</a>
@endforeach
</nav>
<main>
@foreach (products as product)
<ProductCardproduct="" />
@endforeach
</main>
</template>
Database Queries
<scriptserver
import { db } from '../lib/database'
// Direct database access (safe on server)
const posts = await db.posts.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
})
</script>
<template
@foreach (posts as post)
<article>
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
</article>
@endforeach
</template>
Streaming SSR
Enabling Streaming
STX supports streaming for progressive rendering:
// server.ts
import { renderStream } from '@stacksjs/stx'
Bun.serve({
async fetch(request) {
const stream = await renderStream('./pages/index.stx', {
data: { title: 'Home' }
})
return new Response(stream, {
headers: { 'Content-Type': 'text/html' },
})
},
})
Suspense-like Loading
<scriptserver
// This data loads immediately
const header = await fetchHeaderData()
</script>
<template
<Headerdata="" />
<!-- Content loads progressively -->
<Suspense>
<templatedefault
<MainContent />
</template>
<templatefallback
<LoadingSpinner />
</template>
</Suspense>
</template>
Hydration
Client-Side Hydration
STX hydrates server-rendered HTML on the client:
<scriptserver
const initialCount = await fetchCount()
</script>
<scriptclient
// Hydrates with server-rendered state
const counter = document.getElementById('counter')
let count = parseInt(counter.dataset.count)
document.getElementById('increment').addEventListener('click', () => {
count++
counter.textContent = count
})
</script>
<template
<div>
<spanid=""data-count="">{{ initialCount }}</span>
<buttonid=""></button>
</div>
</template>
Client-Side Hydration
For reactive components, use <script client> with signals:
<scriptserver
const initialItems = await fetchItems()
</script>
<scriptclient
const items = state(initialItems)
const newItem = state('')
</script>
<template
<div>
<ul>
<lifor=""key=""x-text=""></li>
</ul>
<inputx-model=""placeholder="" />
<buttonclick="">Add</button>
</div>
</template>
SEO Optimization
Meta Tags
<scriptserver
const page = await fetchPage(props.slug)
useSeoMeta({
title: page.title,
description: page.excerpt,
ogImage: page.ogImage,
})
</script>
<template
<article>
<h1>{{ page.title }}</h1>
{!! page.content !!}
</article>
</template>
useSeoMeta() in <script server> always reaches <head>. In a layout or a
head partial, which already sit inside <head>, @seo(...) does the same job.
It takes any expression that evaluates to an object:
<!--<head> -->
<scriptserver
const seo = {
title: page.title,
description: page.excerpt,
canonical: `https://example.com/${page.slug}`,
openGraph: { image: page.ogImage },
}
</script>
@seo(seo)
@seo writes its tags where it sits, so in a page's body they would land in
<body>, where crawlers ignore them.
Structured Data
<scriptserver
const product = await fetchProduct(props.id)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
price: product.price,
}
</script>
<template
<scripttype=""
{!! JSON.stringify(jsonLd) !!}
</script>
<ProductDisplayproduct="" />
</template>
Error Handling
Try-Catch in Server Scripts
<scriptserver
let data = null
let error = null
try {
data = await fetchData()
} catch (e) {
error = e.message
console.error('Failed to fetch data:', e)
}
</script>
<template
@if (error)
<ErrorMessagemessage="" />
@else
<DataDisplaydata="" />
@endif
</template>
Error Boundaries
<!-- components/ErrorBoundary.stx -->
<scriptserver
let hasError = false
let errorMessage = ''
try {
// Child rendering happens here
} catch (e) {
hasError = true
errorMessage = e.message
}
</script>
<template
@if (hasError)
<divclass="">
<h2>Something went wrong</h2>
<p>{{ errorMessage }}</p>
<buttononclick="">Retry</button>
</div>
@else
<slot />
@endif
</template>
Caching
Response Caching
// server.ts
import { render } from '@stacksjs/stx'
const cache = new Map()
Bun.serve({
async fetch(request) {
const url = new URL(request.url)
const cacheKey = url.pathname
// Check cache
if (cache.has(cacheKey)) {
return new Response(cache.get(cacheKey), {
headers: { 'Content-Type': 'text/html' },
})
}
// Render and cache
const html = await render(`./pages${url.pathname}.stx`)
cache.set(cacheKey, html)
return new Response(html, {
headers: { 'Content-Type': 'text/html' },
})
},
})
Template Caching
Enable in configuration:
// stx.config.ts
export default {
cache: true,
cachePath: '.stx/cache',
cacheTTL: 3600000, // 1 hour
}
Programmatic Rendering
For consumers who need stx's template processing without the full SPA shell — documentation engines, email templates, snippet rendering, etc. — renderString and render are the entry points.
renderString — Inline Template
import { renderString } from '@stacksjs/stx'
const html = await renderString(
'<h1>{{ title }}</h1>',
{ title: 'Hello' }
)
// → <h1>Hello</h1>
templateOnly Mode
Pass { templateOnly: true } to skip injecting the signals runtime, SPA router, and client-script IIFE wrapping. The output is just the processed template — useful when you're embedding stx-rendered fragments inside another runtime that already provides reactivity, or when you only need server-side directives like {{ }}, @if, @foreach, @include, expressions, and includes.
import { renderString } from '@stacksjs/stx'
const fragment = await renderString(
`
@foreach(item in items)
<li>{{ item.name }}</li>
@endforeach
`,
{ items: [{ name: 'Apple' }, { name: 'Pear' }] },
{ templateOnly: true }
)
// → <li>Apple</li><li>Pear</li>
// No <script data-stx-scoped>, no SEO injection, no router setup.
What templateOnly strips after directive processing:
<script data-stx-scoped>blocks (signals runtime + setup IIFE)- SEO injection that targets full-document output
- Client script IIFE wrapping —
<script client>blocks remain as-is for the consumer to handle
What it keeps:
- All directive expansion (
{{ }},{!! !!},@if,@foreach,@include, layouts, components) - Static class strings, attribute interpolation
- Inline
<script>content (passed through unwrapped)
render — From a File
import { render } from '@stacksjs/stx'
const html = await render('./pages/index.stx', {
context: { user: { name: 'Alice' } },
})
render accepts the same options object as renderString, plus a file path. It walks layout/component dependencies for cache invalidation.
Production Deployment
Build for Production
# Build optimized templates
bun run build
# Start production server
NODE_ENV=production bun run start
Environment Configuration
// stx.config.ts
export default {
// Disable debug in production
debug: process.env.NODE_ENV !== 'production',
// Enable caching in production
cache: process.env.NODE_ENV === 'production',
// Minify output
minify: process.env.NODE_ENV === 'production',
}
Performance Tips
- Use streaming for large pages
- Cache aggressively - templates and data
- Minimize server scripts - move logic to shared modules
- Lazy load non-critical components
- Preload data for common routes