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:

<script server>
// 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:

<script client>
// 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

<script server>
// 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)
      <a href="/category/{{ category.slug }}">{{ category.name }}</a>
    @endforeach
  </nav>

  <main>
    @foreach (products as product)
      <ProductCard :product="product" />
    @endforeach
  </main>
</template>

Database Queries

<script server>
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

<script server>
// This data loads immediately
const header = await fetchHeaderData()
</script>

<template>
  <Header :data="header" />

  <!-- Content loads progressively -->
  <Suspense>
    <template #default>
      <MainContent />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

Hydration

Client-Side Hydration

STX hydrates server-rendered HTML on the client:

<script server>
const initialCount = await fetchCount()
</script>

<script client>
// 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>
    <span id="counter" data-count="{{ initialCount }}">{{ initialCount }}</span>
    <button id="increment">+</button>
  </div>
</template>

Client-Side Hydration

For reactive components, use <script client> with signals:

<script server>
const initialItems = await fetchItems()
</script>

<script client>
const items = state(initialItems)
const newItem = state('')
</script>

<template>
  <div>
    <ul>
      <li :for="item in items" :key="item.name" x-text="item.name"></li>
    </ul>

    <input x-model="newItem" placeholder="Add item..." />
    <button @click="items.set([...items(), { name: newItem() }]); newItem.set('')">Add</button>
  </div>
</template>

SEO Optimization

Meta Tags

<script server>
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:

<!-- partials/head.stx, included inside the layout's <head> -->
<script server>
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

<script server>
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>
  <script type="application/ld+json">
    {!! JSON.stringify(jsonLd) !!}
  </script>

  <ProductDisplay :product="product" />
</template>

Error Handling

Try-Catch in Server Scripts

<script server>
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)
    <ErrorMessage message="{{ error }}" />
  @else
    <DataDisplay :data="data" />
  @endif
</template>

Error Boundaries

<!-- components/ErrorBoundary.stx -->
<script server>
let hasError = false
let errorMessage = ''

try {
  // Child rendering happens here
} catch (e) {
  hasError = true
  errorMessage = e.message
}
</script>

<template>
  @if (hasError)
    <div class="error-boundary">
      <h2>Something went wrong</h2>
      <p>{{ errorMessage }}</p>
      <button onclick="location.reload()">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

  1. Use streaming for large pages
  2. Cache aggressively - templates and data
  3. Minimize server scripts - move logic to shared modules
  4. Lazy load non-critical components
  5. Preload data for common routes