STX API Reference

On this page 382

A comprehensive reference for all STX templating syntax, directives, and APIs.

Table of Contents


Single File Components (SFC)

STX uses Vue-like Single File Components with <script>, <template>, and <style> sections. TypeScript is the default — all <script> blocks are transpiled via Bun.Transpiler automatically. No lang="ts" attribute needed.

<!-- components/MyComponent.stx -->
<script server>
// Server-side only - extracted for SSR, stripped from output
const title: string = props.title || 'Default Title'
const count: number = props.count || 0
</script>

<template>
  <div class="card">
    <h1>{{ title }}</h1>
    <p>Count: {{ count }}</p>
    <slot />
  </div>
</template>

<style scoped>
.card {
  padding: 1rem;
  border: 1px solid #ccc;
}
</style>

<script client>
// Client-side only - TypeScript works here too
interface User { name: string; role: string }

const user: User = { name: 'John', role: 'Admin' }
onMount(() => {
  console.log('Component mounted')
})
</script>

To opt out of TypeScript transpilation, use <script js> or <script lang="js">.


Script Types

TypeBehavior
<script server>SSR only - variables extracted, script stripped from output
<script client>Client only - preserved for browser, skips server evaluation
<script>Both - runs on server AND preserved for client

Server Script

<script server>
// Runs during SSR, variables available in template
const user = await fetchUser()
const posts = await getPosts(user.id)
</script>

Client Script

<script client>
// Only runs in browser, never evaluated on server
// Auto-imports available: state, useRef, navigate, useQuery, etc.
onMount(() => {
  console.log('Page loaded')
})
</script>

Template Expressions

Escaped Output (Safe)

{{ variable }}
{{ user.name }}
{{ items.length }}
{{ formatDate(date) }}

Raw HTML Output (Unescaped)

{!! trustedHtml !!}
{!! markdown(content) !!}

Components

Components in components/ are auto-imported using PascalCase naming.

Usage

<!-- Auto-imported from components/UserCard.stx -->
<UserCard name="John" role="Admin" />

<!-- With slot content -->
<Card title="Welcome">
  <p>This content goes into the slot!</p>
</Card>

<!-- Nested in folders: components/forms/Input.stx -->
<FormsInput type="email" />

Explicit Imports

@import('layouts/Sidebar')
@import('shared/Button', 'shared/Modal')

<Sidebar />
<Button label="Click me" />

Props & Slots

Passing Props

<!-- String prop -->
<Card title="Hello" />

<!-- Expression binding with : -->
<Card :count="items.length" :active="isActive" />

<!-- Mustache interpolation -->
<Card title="{{ userName }}" />

<!-- Boolean props -->
<Button disabled />
<Modal :visible="showModal" />

Accessing Props in Components

Simple props access

<script server>
// Via props object
const title = props.title || 'Default'
const count = props.count || 0

// Destructured (also works)
const { name, email } = props
</script>

<template>
  <h1>{{ title }}</h1>
  <p>{{ props.description }}</p>
</template>

Typed props with defineProps (v2)

<script>
interface CardProps {
  title: string
  count?: number
  variant?: 'primary' | 'secondary'
}

const props = defineProps<CardProps>()
</script>

With defaults via withDefaults (v2)

<script>
const props = withDefaults(defineProps<{
  title: string
  variant?: string
  size?: string
}>(), {
  variant: 'primary',
  size: 'md',
})
</script>

With validation

<script>
const props = defineProps({
  title: { required: true },
  count: { default: 0 },
  status: {
    default: 'active',
    validator: (v) => ['active', 'inactive'].includes(v)
  }
})
</script>

Emitting Events (v2)

<script>
const emit = defineEmits<{
  change: string
  submit: { data: Record<string, unknown> }
}>()

function handleClick() {
  emit('change', 'new value')
}
</script>

Parent listens via CustomEvent:

<my-component @change="handleChange($event.detail)" />

Slots

<!-- Default slot -->
<template>
  <div class="wrapper">
    <slot />
  </div>
</template>

<!-- With default content -->
<template>
  <div class="wrapper">
    <slot>Default content if no slot provided</slot>
  </div>
</template>

Layouts & Sections

Defining a Layout

<!-- layouts/default.stx -->
<!DOCTYPE html>
<html>
<head>
  <title>{{ title || 'My App' }}</title>
</head>
<body>
  <Header />
  <main>
    @slot('content')
  </main>
  <Footer />
</body>
</html>

Using a Layout

<!-- pages/about.stx -->
@layout('default')

@section('content')
  <h1>About Us</h1>
  <p>Welcome to our site.</p>
@endsection

Multiple Slots

<!-- Layout -->
<head>
  @slot('head')
</head>
<body>
  @slot('content')
  @slot('scripts')
</body>

<!-- Page -->
@layout('default')

@section('head')
  <link rel="stylesheet" href="/custom.css">
@endsection

@section('content')
  <h1>Page Content</h1>
@endsection

@section('scripts')
  <script src="/custom.js"></script>
@endsection

Directives

Conditionals

@if / @elseif / @else

@if (user.isAdmin)
  <AdminPanel />
@elseif (user.isEditor)
  <EditorTools />
@else
  <UserView />
@endif

@unless

@unless (user.isGuest)
  <UserDashboard />
@endunless

@isset / @empty

@isset (user.avatar)
  <img src="{{ user.avatar }}" />
@endisset

@empty (notifications)
  <p>No notifications</p>
@endempty

Loops

@foreach

@foreach (items as item)
  <li>{{ item.name }}</li>
@endforeach

<!-- With index -->
@foreach (items as item, index)
  <li>{{ index }}: {{ item.name }}</li>
@endforeach

<!-- With key-value -->
@foreach (object as key, value)
  <dt>{{ key }}</dt>
  <dd>{{ value }}</dd>
@endforeach

@for

@for (let i = 0; i < 5; i++)
  <li>Item {{ i }}</li>
@endfor

@while

@while (condition)
  <p>Still true...</p>
@endwhile

@forelse

@forelse (users as user)
  <li>{{ user.name }}</li>
@empty
  <li>No users found</li>
@endforelse

Auth Guards

@auth
  <p>Welcome back, {{ user.name }}!</p>
  <LogoutButton />
@endauth

@guest
  <a href="/login">Please log in</a>
@endguest

<!-- With specific guard -->
@auth('admin')
  <AdminControls />
@endauth

Environment

@env('production')
  <Analytics />
@endenv

@env('local', 'development')
  <DebugBar />
@endenv

Includes & Partials

<!-- Basic include -->
@include('partials/header')

<!-- With data -->
@include('partials/user-card', { user: currentUser, showAvatar: true })

<!-- Conditional include -->
@includeIf('partials/optional')

<!-- Include when condition is true -->
@includeWhen(user.isAdmin, 'partials/admin-tools')

<!-- Include first existing -->
@includeFirst(['custom/header', 'default/header'])

Stack & Push

<!-- In layout: define a stack -->
@stack('scripts')
@stack('styles')

<!-- In components/pages: push to stack -->
@push('scripts')
  <script src="/component-specific.js"></script>
@endpush

@push('styles')
  <link rel="stylesheet" href="/component.css">
@endpush

<!-- Prepend to stack -->
@prepend('scripts')
  <script src="/load-first.js"></script>
@endprepend

@once

<!-- Only renders once even if component is used multiple times -->
@once
  <script src="/shared-library.js"></script>
@endonce

Forms

<form method="POST">
  @csrf
  <!-- Generates: <input type="hidden" name="_token" value="..."> -->
</form>

@method

<form method="POST">
  @method('PUT')
  <!-- Generates: <input type="hidden" name="_method" value="PUT"> -->
</form>

@error

<input name="email" type="email">
@error('email')
  <span class="error">{{ message }}</span>
@enderror

SEO

@meta('description', 'Page description here')
@meta('keywords', 'stx, templating, framework')
@meta('author', post.author)            <!-- unquoted: an expression -->

@seo({
  title: 'Page Title',
  description: 'Page description',
  openGraph: { image: '/og-image.jpg', type: 'article' }
})

@seo(seo)                                <!-- any expression that evaluates to an object -->
@structuredData(product)

Arguments are expressions evaluated against the template's context; a quoted string is literal text. A directive that cannot produce its tag (a variable that is not defined, a value of the wrong type) leaves an HTML comment in its place and logs a [stx] warning naming the file. See the directives reference.


Prefix Convention

stx uses three prefixes to clearly separate concerns:

PrefixPurposeExamples
:Structural directives — control flow:if, :show, :for, :key
x-Attribute bindings & content — dynamic valuesx-class, x-style, x-href, x-src, x-text, x-html, x-model, x-cloak
@Event listeners@click, @submit, @keydown.enter, @click.prevent

Structural Directives (: prefix)

<div :if="isLoggedIn">Welcome back</div>
<div :show="menuOpen">Menu content</div>
<div :for="item in items" :key="item.id">{{ item.name }}</div>

Attribute Bindings (x- prefix)

<!-- Dynamic attributes -->
<a x-href="'/user/' + userId">Profile</a>
<img x-src="user.avatar" x-alt="user.name" />

<!-- Dynamic classes and styles -->
<div x-class="isActive ? 'bg-green-500' : 'bg-gray-200'">Status</div>
<div x-style="'color: ' + themeColor">Themed</div>

<!-- Content -->
<span x-text="userName"></span>
<div x-html="richContent"></div>

<!-- Form binding -->
<input x-model="email" type="email" placeholder="Email">

Events (@ prefix)

<button @click="handleClick()">Click me</button>
<button @click="count.set(count() + 1)">Increment</button>
<form @submit.prevent="submitForm()">...</form>
<input @keydown.enter="search()">

Reactivity

All client-side state lives in <script client> blocks using signals. x-data is deprecated — state belongs in TypeScript, not inline HTML attributes.

<script client>
  const count = state(0)
  const email = state('')
  const doubled = derived(() => count() * 2)
</script>

<span x-text="count()"></span>
<button @click="count.set(count() + 1)">+1</button>
<p>Doubled: <span x-text="doubled()"></span></p>

<input x-model="email" type="email">
<p>Email: <span x-text="email()"></span></p>

x-model

Two-way binding for form inputs:

<script client>
  const email = state('')
  const password = state('')
</script>

<input x-model="email" type="email" placeholder="Email">
<input x-model="password" type="password" placeholder="Password">
<p>Email: <span x-text="email()"></span></p>

x-text

Reactive text content:

<script client>
  const count = state(0)
</script>

<span x-text="count()"></span>
<button @click="count.set(count() + 1)">Increment</button>

Event Handling

@click

<button @click="handleClick()">Click me</button>
<button @click="count++">Increment</button>
<button @click="visible = !visible">Toggle</button>

Other Events

<input @input="handleInput($event)">
<input @change="handleChange($event)">
<form @submit="handleSubmit($event)">
<div @mouseover="isHovered = true" @mouseout="isHovered = false">

Event Modifiers

<!-- Prevent default -->
<form @submit.prevent="handleSubmit()">

<!-- Stop propagation -->
<button @click.stop="handleClick()">

<!-- Once only -->
<button @click.once="initOnce()">

<!-- Key modifiers -->
<input @keyup.enter="submit()">
<input @keyup.escape="cancel()">

Icons

STX integrates with Iconify for 200K+ icons.

Usage

<HomeIcon size="24" />
<SearchIcon size="20" color="#333" />
<UserIcon size="32" class="text-blue-500" />

CLI Commands

# List available icon collections
stx iconify list

# Generate icon package for a collection
stx iconify generate material-symbols
stx iconify generate heroicons

Custom Directives

Registering Custom Directives

import { stxPlugin, type CustomDirective } from 'bun-plugin-stx'

// Simple directive
const uppercase: CustomDirective = {
  name: 'uppercase',
  handler: (content, params) => params[0]?.toUpperCase() || content.toUpperCase()
}

// Block directive with end tag
const wrap: CustomDirective = {
  name: 'wrap',
  hasEndTag: true,
  handler: (content, params) => `<div class="${params[0] || 'wrapper'}">${content}</div>`
}

// Async directive
const fetch: CustomDirective = {
  name: 'fetch',
  async: true,
  handler: async (content, params) => {
    const response = await fetch(params[0])
    return await response.text()
  }
}

Bun.build({
  entrypoints: ['./src/index.stx'],
  plugins: [stxPlugin({
    customDirectives: [uppercase, wrap, fetch]
  })]
})

Using Custom Directives

<!-- Inline directive -->
<p>@uppercase('hello world')</p>
<!-- Output: HELLO WORLD -->

<!-- Block directive -->
@wrap('container')
  <p>Wrapped content</p>
@endwrap
<!-- Output: <div class="container"><p>Wrapped content</p></div> -->

Configuration

stx.config.ts

import type { StxConfig } from '@stacksjs/stx'

const config: StxConfig = {
  // Directory configuration
  pagesDir: 'pages',
  componentsDir: 'components',
  layoutsDir: 'layouts',
  partialsDir: 'partials',
  publicDir: 'public',

  // Default layout for pages
  defaultLayout: 'default',

  // Caching
  cache: process.env.NODE_ENV === 'production',
  cachePath: '.stx/cache',

  // Debug mode
  debug: process.env.NODE_ENV !== 'production',

  // Accessibility
  a11y: {
    enabled: true,
    level: 'AA', // 'A', 'AA', or 'AAA'
  },

  // SEO
  seo: {
    enabled: true,
    socialPreview: true,
    defaultConfig: {
      title: 'My App',
      description: 'App description',
    },
  },

  // Internationalization
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'es', 'fr'],
    directory: 'locales',
  },

  // Custom directives
  customDirectives: [],

  // Middleware
  middleware: {
    before: [],
    after: [],
  },
}

export default config

State Management

STX provides a clean, Pinia-inspired state management system with reactive stores.

Defining Stores

Create stores in a dedicated file (e.g., stores/index.ts):

// stores/index.ts
import { defineStore, registerStoresClient } from 'stx'

// Define a store with state, getters, and actions
export const counterStore = defineStore('counter', {
  state: {
    count: 0,
    name: 'My Counter'
  },

  getters: {
    doubleCount: (state) => state.count * 2,
    displayName: (state) => `${state.name}: ${state.count}`
  },

  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    incrementBy(amount: number) {
      this.count += amount
    },
    async fetchCount() {
      const response = await fetch('/api/count')
      this.count = await response.json()
    }
  },

  // Optional: persist to localStorage
  persist: true
})

export const appStore = defineStore('app', {
  state: {
    isLoading: false,
    user: null as { name: string; email: string } | null,
    theme: 'dark'
  },

  getters: {
    isAuthenticated: (state) => state.user !== null
  },

  actions: {
    setUser(user: { name: string; email: string } | null) {
      this.user = user
    },
    toggleTheme() {
      this.theme = this.theme === 'dark' ? 'light' : 'dark'
    }
  },

  persist: {
    storage: 'local',
    key: 'my-app-state'
  }
})

// Register stores for client-side @stores imports
if (typeof window !== 'undefined') {
  registerStoresClient({ counterStore, appStore })
}

Using Stores in Components

Import stores using the clean @stores syntax:

<script client>
import { counterStore, appStore } from '@stores'

// Access state directly
console.log(counterStore.count)        // 0
console.log(appStore.theme)            // 'dark'

// Use getters
console.log(counterStore.doubleCount)  // 0
console.log(appStore.isAuthenticated)  // false

// Call actions
counterStore.increment()
counterStore.incrementBy(5)
appStore.toggleTheme()

// Direct state mutation
counterStore.count = 10
appStore.theme = 'light'

// Subscribe to changes
counterStore.$subscribe((state, prevState) => {
  console.log('Count changed:', prevState?.count, '->', state.count)
})

// Patch multiple values at once
appStore.$patch({
  isLoading: true,
  theme: 'dark'
})

// Or with a function
appStore.$patch((state) => {
  state.isLoading = false
  state.user = { name: 'John', email: 'john@example.com' }
})

// Reset to initial state
counterStore.$reset()
</script>

<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment()">+</button>
    <button @click="counterStore.decrement()">-</button>
  </div>
</template>

Store API Reference

Property/MethodDescription
store.propertyAccess state property directly
store.getterAccess computed getter
store.action()Call an action
store.$stateGet full state snapshot
store.$subscribe(callback)Subscribe to state changes
store.$patch(partial)Update multiple state properties
store.$reset()Reset to initial state
store.$idGet store ID/name

Persistence Options

// Simple persistence (localStorage with auto key)
persist: true

// Custom persistence
persist: {
  storage: 'local',     // 'local' | 'session' | 'memory'
  key: 'my-custom-key'  // Custom storage key
}

Loading Stores in HTML

Include your stores before other scripts:

<!DOCTYPE html>
<html>
<head>
  <!-- Load store runtime -->
  <script src="/js/stores.js"></script>
</head>
<body>
  <!-- Your app content -->
  <script client>
  import { appStore } from '@stores'
  // Stores are immediately available
  console.log(appStore.theme)
  </script>
</body>
</html>

Waiting for Stores

If you need to ensure a store is loaded:

<script client>
import { waitForStore } from 'stx'

// Wait for store with timeout
const appStore = await waitForStore('app', 5000)
console.log(appStore.theme)
</script>

TypeScript Support

Stores are fully typed:

import { defineStore } from 'stx'

interface User {
  id: number
  name: string
  email: string
}

interface AppState {
  user: User | null
  isLoading: boolean
  errors: string[]
}

export const appStore = defineStore('app', {
  state: {
    user: null,
    isLoading: false,
    errors: []
  } as AppState,

  getters: {
    isAuthenticated: (state): boolean => state.user !== null,
    errorCount: (state): number => state.errors.length
  },

  actions: {
    async login(email: string, password: string): Promise<void> {
      this.isLoading = true
      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify({ email, password })
        })
        this.user = await response.json()
      } catch (e) {
        this.errors.push('Login failed')
      } finally {
        this.isLoading = false
      }
    },

    logout(): void {
      this.user = null
    }
  }
})

Lifecycle Hooks

Vue-inspired lifecycle hooks for managing component setup, cleanup, and updates.

onMount

Called when the component is mounted to the DOM. Perfect for setup, event listeners, and subscriptions.

<script client>
// onMount is auto-imported — no import needed

onMount(() => {
  console.log('Component mounted!')

  // Use useEventListener for auto-cleanup (preferred over raw addEventListener)
  const { remove } = useEventListener('click', () => console.log('clicked'))

  // Return cleanup function (called on unmount)
  return () => {
    remove()
  }
})
</script>

onDestroy

Called when the component is removed from the DOM. Use for cleanup.

<script client>
import { onMount, onDestroy } from 'stx'

onMount(() => {
  const interval = setInterval(() => {
    console.log('tick')
  }, 1000)

  // Can also register cleanup in onMount's return
  onDestroy(() => {
    clearInterval(interval)
  })
})
</script>

onUpdate

Called when the component updates (re-renders).

<script client>
import { onUpdate } from 'stx'

onUpdate(() => {
  console.log('Component updated!')
})
</script>

Aliases

HookAlias
onMountonMounted
onDestroyonUnmounted
onUpdateonUpdated

Full Example

<script client>
import { onMount, onDestroy, onUpdate } from 'stx'
import { appStore } from '@stores'

onMount(() => {
  console.log('Chat component mounted')

  // Subscribe to store
  const unsubscribe = appStore.$subscribe((state) => {
    console.log('App state changed:', state)
  })

  // WebSocket connection
  const ws = new WebSocket('wss://api.example.com/chat')
  ws.onmessage = (e) => handleMessage(JSON.parse(e.data))

  // Cleanup
  return () => {
    unsubscribe()
    ws.close()
  }
})

onUpdate(() => {
  // Scroll to bottom when new messages arrive
  const messages = useRef('messages')
  messages.current?.scrollTo(0, messages.current.scrollHeight)
})
</script>

Refs (DOM References)

Direct references to DOM elements, similar to Vue's ref or React's useRef.

The useRef() composable is the preferred way to reference DOM elements. It is auto-imported — no import statement needed.

<template>
  <input type="text" ref="myInput" placeholder="Auto-focused" />
  <button @click="myInput.current?.select()">Select All</button>
</template>

<script client>
// useRef is auto-imported — no import needed
const myInput = useRef('myInput')

onMount(() => {
  myInput.current?.focus()
})
</script>

A ref reads three ways, all the same value: call it like every other stx accessor, or read .current / .value if the surrounding code is shaped that way. All three answer null until the element is bound.

const shell = useRef('shell')

shell() // → HTMLElement | null
shell.current // → the same
shell.value // → the same

// Safe inside an effect, which runs its body immediately:
effect(() => {
  if (shell())
    console.log(shell().dataset.flag)
})

Binding Refs

Use ref="name", @ref="name", or :ref="name" to bind elements:

<template>
  <!-- All three syntaxes work identically -->
  <input ref="emailRef" type="email" />
  <input @ref="passwordRef" type="password" />
  <div :ref="containerRef">...</div>
</template>

<script client>
const email = useRef('emailRef')
const password = useRef('passwordRef')
const container = useRef('containerRef')

onMount(() => {
  email.current?.focus()
})
</script>

Multiple Refs

<template>
  <form ref="formRef">
    <input ref="emailRef" type="email" />
    <input ref="passwordRef" type="password" />
    <button type="submit">Login</button>
  </form>
</template>

<script client>
const form = useRef('formRef')
const email = useRef('emailRef')
const password = useRef('passwordRef')

onMount(() => {
  form.current?.addEventListener('submit', (e) => {
    e.preventDefault()
    console.log('Email:', email.current?.value)
    console.log('Password:', password.current?.value)
  })
})
</script>

useRef API

PropertyDescription
ref.valueThe DOM element (or null if not bound)
ref.currentAlias for ref.value

Legacy ref() (Still Supported)

The Vue-style ref() function also works for creating refs:

<script client>
import { ref, onMount } from 'stx'
const inputRef = ref<HTMLInputElement>()
onMount(() => inputRef.value?.focus())
</script>

Transitions

CSS-based enter/leave transitions for elements, inspired by Vue's transition system.

Basic Usage

@transition(name: 'fade')
  @if (visible)
    <div class="modal">Modal Content</div>
  @endif
@endtransition

Transition Options

OptionDescriptionDefault
nameCSS class prefix'stx'
durationDuration in ms300
mode'in-out', 'out-in', or 'default''default'
@transition(name: 'slide-up', duration: 500, mode: 'out-in')
  @if (currentTab === 'home')
    <HomeTab />
  @elseif (currentTab === 'settings')
    <SettingsTab />
  @endif
@endtransition

Attribute Syntax

For simpler cases, use the attribute syntax:

<div @transition.fade="isVisible">Fading content</div>
<div @transition.slide-up.500="isOpen">Sliding content</div>

CSS Classes

STX applies these CSS classes during transitions:

Enter Transition:

ClassWhen Applied
{name}-enter-fromInitial state before entering
{name}-enter-activeActive state during enter
{name}-enter-toFinal state after entering

Leave Transition:

ClassWhen Applied
{name}-leave-fromInitial state before leaving
{name}-leave-activeActive state during leave
{name}-leave-toFinal state after leaving

Built-in Transitions

STX includes these ready-to-use transitions:

/* Available transition names */
fade        /* Opacity fade */
slide-up    /* Slide from bottom */
slide-down  /* Slide from top */
slide-left  /* Slide from right */
slide-right /* Slide from left */
scale       /* Scale from 90% */
scale-up    /* Scale from 50% */
bounce      /* Bouncy scale */
flip        /* 3D flip */
zoom        /* Zoom from 0 */
collapse    /* Height collapse */

Custom Transition CSS

Define your own transitions:

/* Custom 'swing' transition */
.swing-enter-from,
.swing-leave-to {
  opacity: 0;
  transform: translateX(-100%) rotate(-30deg);
}

.swing-enter-active,
.swing-leave-active {
  transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}

.swing-enter-to,
.swing-leave-from {
  opacity: 1;
  transform: translateX(0) rotate(0);
}
@transition(name: 'swing')
  @if (showMenu)
    <nav class="menu">...</nav>
  @endif
@endtransition

JavaScript Transition Hooks

For programmatic control:

<script client>
// Enter transition
STX.transition.enter(element, {
  name: 'fade',
  duration: 300
})

// Leave transition (returns Promise)
await STX.transition.leave(element, {
  name: 'slide-up',
  duration: 500
})

// Toggle with transition
STX.transition.toggle(element, isVisible, {
  name: 'scale'
})
</script>
<template>
  <button @click="showModal = true">Open Modal</button>

  @transition(name: 'fade')
    @if (showModal)
      <div class="modal-overlay" @click.self="showModal = false">
        @transition(name: 'scale')
          <div class="modal-content">
            <h2>Modal Title</h2>
            <p>Modal content goes here...</p>
            <button @click="showModal = false">Close</button>
          </div>
        @endtransition
      </div>
    @endif
  @endtransition
</template>

<style>
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
}
.modal-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  max-width: 500px;
}
</style>

Scoped Slots

Scoped slots allow parent components to receive data from child components via slot props.

Named Slots

Basic named slots for content distribution:

<!-- Card.stx (Child Component) -->
<div class="card">
  <header>
    <slot name="header">Default Header</slot>
  </header>
  <main>
    <slot />  <!-- Default slot -->
  </main>
  <footer>
    <slot name="footer" />
  </footer>
</div>

<!-- Parent Usage -->
<Card>
  <template #header>
    <h1>Custom Title</h1>
  </template>

  <p>This goes in the default slot</p>

  <template #footer>
    <button>Submit</button>
  </template>
</Card>

Scoped Slots with Props

Pass data from child to parent via slot props:

<!-- DataTable.stx (Child Component) -->
<script>
const items = props.items || []
</script>

<table>
  <tbody>
    @foreach (items as item, index)
      <tr>
        <slot name="row" :item="item" :index="index">
          <!-- Default content if no slot provided -->
          <td>{{ item }}</td>
        </slot>
      </tr>
    @endforeach
  </tbody>
</table>

<!-- Parent Usage -->
<DataTable :items="users">
  <template #row="{ item, index }">
    <td>{{ index + 1 }}</td>
    <td>{{ item.name }}</td>
    <td>{{ item.email }}</td>
    <td>
      <button @click="editUser(item.id)">Edit</button>
    </td>
  </template>
</DataTable>

Slot Syntax Variants

<!-- Hash syntax (recommended) -->
<template #header>...</template>
<template #row="{ item }">...</template>

<!-- v-slot syntax (Vue compatible) -->
<template v-slot:header>...</template>
<template v-slot:row="{ item }">...</template>

<!-- slot attribute (legacy) -->
<template slot="header">...</template>

Default Slot Content

Provide fallback content when no slot is provided:

<!-- Button.stx -->
<button class="btn">
  <slot>Click Me</slot>  <!-- "Click Me" is the default -->
</button>

<!-- Usage -->
<Button>Submit</Button>    <!-- Shows "Submit" -->
<Button />                 <!-- Shows "Click Me" -->

Provide/Inject (Dependency Injection)

Provide/Inject allows ancestor components to pass data to any descendant without prop drilling.

Basic Usage

<!-- Parent.stx -->
<script>
import { provide } from 'stx'

const theme = 'dark'
const user = { name: 'John', role: 'admin' }

// Provide values to all descendants
provide('theme', theme)
provide('user', user)
</script>

<div class="app">
  <Header />
  <Content />
  <Footer />
</div>
<!-- DeepNestedChild.stx (any descendant) -->
<script>
import { inject } from 'stx'

// Inject values from any ancestor
const theme = inject('theme')
const user = inject('user')
</script>

<div class="{{ theme === 'dark' ? 'bg-gray-900' : 'bg-white' }}">
  <p>Welcome, {{ user.name }}!</p>
</div>

Default Values

Provide fallback values when injection is not found:

<script>
import { inject } from 'stx'

// With default value
const theme = inject('theme', 'light')
const locale = inject('locale', 'en-US')
</script>

Typed Injection Keys

Use typed keys for better type safety:

<script>
import { createInjectionKey, provide, inject } from 'stx'

// Create a typed injection key
const ThemeKey = createInjectionKey<'light' | 'dark'>('theme')

// Provider
provide(ThemeKey, 'dark')

// Consumer (type-safe)
const theme = inject(ThemeKey) // Type: 'light' | 'dark' | undefined
</script>

Reactive Injection

Inject reactive state that updates descendants:

<!-- Provider.stx -->
<script>
import { provide, ref } from 'stx'

const count = ref(0)
provide('count', count)

function increment() {
  count.value++
}
</script>

<!-- Consumer.stx -->
<script>
import { inject, watch } from 'stx'

const count = inject('count')

watch(count, (newVal) => {
  console.log('Count changed:', newVal)
})
</script>

<p>Count: {{ count.value }}</p>

Common Patterns

<!-- Theme Provider -->
<script>
import { provide, ref } from 'stx'

const theme = ref('light')
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide('theme', { theme, toggleTheme })
</script>

<!-- Consuming component -->
<script>
import { inject } from 'stx'

const { theme, toggleTheme } = inject('theme')
</script>

<button @click="toggleTheme">
  Switch to {{ theme.value === 'light' ? 'Dark' : 'Light' }} Mode
</button>

Error Boundaries

Error boundaries catch JavaScript errors anywhere in their child component tree and display a fallback UI instead of crashing the entire application.

Basic Usage

@errorBoundary
  <RiskyComponent />
@fallback
  <div class="error-state">
    <p>Something went wrong</p>
    <button @click="$retry()">Retry</button>
  </div>
@enderrorBoundary

With Options

@errorBoundary(id: 'user-section', logErrors: false)
  <UserProfile :id="userId" />
  <UserActivity :id="userId" />
@fallback
  <div class="error-fallback">
    <h3>Failed to load user data</h3>
    <button @click="$retry()">Try Again</button>
  </div>
@enderrorBoundary

Options Reference

OptionTypeDefaultDescription
idstringauto-generatedCustom ID for error tracking
logErrorsbooleantrueWhether to log errors to console

Retry Functionality

The $retry() function resets the error boundary and re-renders the content:

@errorBoundary
  <DataGrid :data="largeDataset" />
@fallback
  <div class="error-ui">
    <span class="icon">⚠️</span>
    <h4>Data failed to load</h4>
    <p>Check your connection and try again.</p>
    <button @click="$retry()">Retry</button>
  </div>
@enderrorBoundary

Nested Error Boundaries

Error boundaries can be nested for granular error handling:

@errorBoundary(id: 'page')
  <Header />

  @errorBoundary(id: 'sidebar')
    <Sidebar />
  @fallback
    <div>Sidebar unavailable</div>
  @enderrorBoundary

  @errorBoundary(id: 'main-content')
    <MainContent />
  @fallback
    <div>Content failed to load</div>
  @enderrorBoundary

  <Footer />
@fallback
  <div>Page crashed. Please refresh.</div>
@enderrorBoundary

SSR Error Handling

For server-side rendering, use the programmatic API:

import { withErrorBoundary } from 'stx'

const html = await withErrorBoundary(
  async () => renderComponent(props),
  '<div>Failed to render</div>',
  (error) => console.error('Render error:', error)
)

Programmatic API

Create error boundaries programmatically:

<script client>
// Access error boundary state
const boundary = window.STX.errorBoundaries['eb-123-abc']

// Check if error occurred
if (boundary.hasError()) {
  console.log('Error:', boundary.getError())
}

// Programmatically show fallback
boundary.showFallback(new Error('Custom error'))

// Programmatically retry
boundary.retry()
</script>

Event Handling

Listen for error boundary events:

<script client>
document.addEventListener('stx:error', (e) => {
  const { error, boundaryId } = e.detail
  // Report to error tracking service
  errorTracker.report(error, { boundaryId })
})

document.addEventListener('stx:retry', (e) => {
  const { boundaryId } = e.detail
  console.log(`Boundary ${boundaryId} retrying...`)
})
</script>

Error Boundary CSS

Default styles are included, but can be customized:

/* Custom error fallback styling */
.stx-error-boundary-fallback {
  padding: 2rem;
  background: linear-gradient(135deg, #fee2e2, #fecaca);
  border: 2px solid #ef4444;
  border-radius: 12px;
  text-align: center;
}

.stx-error-boundary-fallback button {
  margin-top: 1rem;
  padding: 0.75rem 1.5rem;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 600;
}

.stx-error-boundary-fallback button:hover {
  background: #dc2626;
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
  .stx-error-boundary-fallback {
    background: linear-gradient(135deg, #450a0a, #7f1d1d);
    color: #fecaca;
  }
}

Async Components (@async)

Lazy load components with loading and error states.

Basic Usage

@async(component: 'HeavyChart')
  <div class="loading">
    <div class="stx-async-spinner"></div>
    <p>Loading chart...</p>
  </div>
@error
  <div class="error">
    <p>Failed to load chart</p>
    <button onclick="$retry()">Retry</button>
  </div>
@endasync

Options

OptionTypeDefaultDescription
componentstringrequiredComponent path or name to load
timeoutnumber30000Timeout in milliseconds
delaynumber200Delay before showing loading state
retriesnumber0Number of retry attempts on failure
suspensiblebooleantrueWhether to notify parent Suspense boundary

With All Options

@async(component: 'Dashboard', timeout: 10000, delay: 100, retries: 3)
  <DashboardSkeleton />
@error
  <div class="error-state">
    <h4>Dashboard failed to load</h4>
    <p data-error-message></p>
    <button onclick="$retry()">Try Again</button>
  </div>
@endasync

Programmatic API

import { defineAsyncComponent } from 'stx'

// Simple async component
const AsyncChart = defineAsyncComponent(() => import('./Chart.stx'))

// With full options
const AsyncDashboard = defineAsyncComponent({
  loader: () => import('./Dashboard.stx'),
  loadingComponent: '<div class="skeleton"></div>',
  errorComponent: '<div class="error">Failed</div>',
  delay: 200,
  timeout: 10000,
  onLoadStart: () => console.log('Loading...'),
  onLoadEnd: () => console.log('Loaded!'),
  onError: (error, retry, fail, attempts) => {
    if (attempts < 3) retry()
    else fail()
  }
})

Client-Side Loading

<script client>
// Load a component dynamically
await STX.loadComponent('HeavyWidget', '#widget-container', {
  loading: '<div class="spinner"></div>',
  error: '<div class="error">Failed to load</div>',
  onLoad: () => console.log('Widget loaded'),
  onError: (err) => console.error(err)
})
</script>

Events

<script client>
// Listen for async component events
document.addEventListener('stx:async:loaded', (e) => {
  console.log('Component loaded:', e.detail.component)
})

document.addEventListener('stx:async:error', (e) => {
  console.error('Load failed:', e.detail.error)
})
</script>

Suspense

Coordinate async loading across component trees, showing a single fallback until all async children resolve.

Basic Usage

@suspense
  <UserProfile :id="userId" />
  <UserActivity :id="userId" />
  <UserStats :id="userId" />
@fallback
  <PageSkeleton />
@endsuspense

With Timeout

@suspense(timeout: 10000)
  @async(component: 'Header')
    <HeaderSkeleton />
  @endasync

  @async(component: 'Sidebar')
    <SidebarSkeleton />
  @endasync

  @async(component: 'MainContent')
    <ContentSkeleton />
  @endasync
@fallback
  <FullPageLoader />
@endsuspense

Nested Suspense

@suspense(id: 'page')
  <Header />

  @suspense(id: 'content')
    @async(component: 'ArticleList')
      <ArticleListSkeleton />
    @endasync
  @fallback
    <ContentLoader />
  @endsuspense

  <Footer />
@fallback
  <PageLoader />
@endsuspense

Events

<script client>
// All async children resolved
document.addEventListener('stx:suspense:resolved', (e) => {
  console.log('Suspense resolved:', e.detail.suspenseId)
})

// Timeout occurred
document.addEventListener('stx:suspense:timeout', (e) => {
  console.log('Pending components:', e.detail.pending)
})

// Child failed to load
document.addEventListener('stx:suspense:error', (e) => {
  console.error('Child failed:', e.detail.childId, e.detail.error)
})
</script>

Programmatic API

<script client>
// Wait for all suspense boundaries to resolve
await STX.suspense.waitForAll()
console.log('All content loaded!')

// Access specific boundary
const boundary = STX.suspense.boundaries['suspense-1-abc']
console.log('Status:', boundary.getStatus())
console.log('Pending:', boundary.getPending())
boundary.reset() // Reset and reload
</script>

SSR Streaming

import { createSSRSuspense, renderSSRSuspense } from 'stx'

// Create suspense boundaries for streaming
const boundaries = [
  createSSRSuspense('header', renderHeader(), '<HeaderSkeleton />'),
  createSSRSuspense('content', renderContent(), '<ContentSkeleton />'),
]

// Render with streaming
const { initial, streaming } = await renderSSRSuspense(boundaries)

// Send initial HTML with fallbacks
res.write(initial)

// Stream resolved content as it becomes available
for await (const chunk of streaming) {
  res.write(chunk)
}

res.end()

Keep-Alive

Cache component instances to preserve state when toggling visibility.

Basic Usage

@keepAlive
  @if (currentTab === 'settings')
    <SettingsPanel />
  @elseif (currentTab === 'profile')
    <ProfilePanel />
  @elseif (currentTab === 'activity')
    <ActivityPanel />
  @endif
@endkeepAlive

With Options

@keepAlive(max: 5, include: 'Settings,Profile', exclude: 'Debug')
  <component :is="currentComponent" />
@endkeepAlive

Options

OptionTypeDefaultDescription
maxnumber10Maximum cached instances (LRU eviction)
includestring-Comma-separated component names to cache
excludestring-Comma-separated component names to NOT cache
idstringautoCustom cache ID

Tab Navigation Example

<script>
let activeTab = 'home'
</script>

<nav>
  <button @click="activeTab = 'home'" class="{{ activeTab === 'home' ? 'active' : '' }}">
    Home
  </button>
  <button @click="activeTab = 'search'" class="{{ activeTab === 'search' ? 'active' : '' }}">
    Search
  </button>
  <button @click="activeTab = 'settings'" class="{{ activeTab === 'settings' ? 'active' : '' }}">
    Settings
  </button>
</nav>

@keepAlive(max: 3)
  @if (activeTab === 'home')
    <HomePage />
  @elseif (activeTab === 'search')
    <SearchPage />  <!-- Form state preserved! -->
  @elseif (activeTab === 'settings')
    <SettingsPage />
  @endif
@endkeepAlive

Preserving Scroll Position

<!-- Add data-keep-scroll to scrollable elements -->
<div class="scrollable-list" data-keep-scroll="main-list">
  @foreach (items as item)
    <ListItem :item="item" />
  @endforeach
</div>

Preserving Form State

<!-- Form values are automatically preserved -->
<form>
  <input name="search" type="text" />
  <select name="filter">
    <option value="all">All</option>
    <option value="active">Active</option>
  </select>
  <input type="checkbox" name="showArchived" />
</form>

<!-- For custom state, use data-keep-state -->
<div data-keep-state="counter" data-state-value="{{ count }}">
  Count: {{ count }}
</div>

Lifecycle Hooks

<script client>
import { onActivated, onDeactivated } from 'stx'

// Called when restored from cache
onActivated((detail) => {
  console.log('Component activated:', detail.key)
  console.log('Was cached at:', new Date(detail.cachedAt))
  // Refresh data if needed
  fetchLatestData()
})

// Called when being cached
onDeactivated((detail) => {
  console.log('Component deactivated:', detail.key)
  // Cleanup if needed
  pauseAnimations()
})
</script>

Programmatic API

<script client>
// Access keep-alive instance
const keepAlive = STX.keepAlive.instances['keep-alive-1-abc']

// Check cache
console.log('Cached keys:', keepAlive.getCacheKeys())
console.log('Cache size:', keepAlive.getCacheSize())

// Check if component is cached
if (keepAlive.isCached('SettingsPanel')) {
  console.log('Settings are cached')
}

// Clear specific component
keepAlive.clear('SettingsPanel')

// Clear all cached components
keepAlive.clear()

// Global utilities
STX.keepAlive.clearAll() // Clear all keep-alive caches
STX.keepAlive.getTotalCached() // Get total cached count
</script>

Events

<script client>
// Component restored from cache
document.addEventListener('stx:activated', (e) => {
  console.log('Activated:', e.detail.key)
})

// Component cached
document.addEventListener('stx:deactivated', (e) => {
  console.log('Deactivated:', e.detail.key)
})
</script>

TypeScript Props

Type-safe component props with runtime validation.

Basic Usage

<script>
import { defineProps } from 'stx'

// Simple typed props
const props = defineProps<{
  title: string
  count: number
  items: string[]
}>()
</script>

<h1>{{ props.title }}</h1>
<p>Count: {{ props.count }}</p>

With Validation and Defaults

<script>
import { defineProps } from 'stx'

const props = defineProps<{
  title: string
  count?: number
  status: 'active' | 'inactive'
  items?: string[]
}>({
  title: { required: true },
  count: { default: 0 },
  status: {
    default: 'active',
    validator: (v) => ['active', 'inactive'].includes(v)
  },
  items: { default: () => [] }
})
</script>

Prop Options

OptionTypeDescription
typeConstructorExpected type (String, Number, Boolean, Array, Object, etc.)
requiredbooleanWhether the prop is required
defaultT | () => TDefault value (use function for objects/arrays)
validator(value) => booleanCustom validation function
descriptionstringDocumentation description

With Defaults Helper

<script>
import { defineProps, withDefaults } from 'stx'

const props = withDefaults(defineProps<{
  title: string
  count?: number
  items?: string[]
}>(), {
  count: 0,
  items: () => []
})
</script>

Prop Helper Functions

import { prop, required, optional, validated, oneOf, arrayOf } from 'stx'

const props = defineProps({
  // Required string
  name: required(String),

  // Optional with default
  count: optional(0, Number),

  // With custom validator
  email: validated(
    (v) => /[\s@]+@[\s@]+\.[\s@]+$/.test(v),
    { type: String, required: true }
  ),

  // One of specific values
  status: oneOf(['active', 'inactive', 'pending'] as const),

  // Array of specific type
  tags: arrayOf(String, { default: () => [] })
})

Runtime Validation

<script>
import { definePropsWithValidation } from 'stx'

const { props, validation } = definePropsWithValidation({
  title: { type: String, required: true },
  count: { type: Number, default: 0 }
}, {
  componentName: 'MyComponent',
  throwOnError: false,  // Don't throw, just log warnings
  logWarnings: true
})

if (!validation.valid) {
  console.error('Prop errors:', validation.errors)
}
</script>

Define Emits (Events)

<script>
import { defineEmits } from 'stx'

const emit = defineEmits<{
  'update:value': string
  'submit': { data: FormData }
  'close': void
}>()

function handleSubmit(data: FormData) {
  emit('submit', { data })
}

function handleClose() {
  emit('close', undefined)
}
</script>

<button @click="handleClose">Close</button>

Define Expose

Expose methods/properties to parent components:

<script>
import { defineExpose, ref } from 'stx'

const inputRef = ref<HTMLInputElement>()
const internalValue = ref('')

defineExpose({
  focus: () => inputRef.value?.focus(),
  reset: () => { internalValue.value = '' },
  getValue: () => internalValue.value
})
</script>

<input @ref="inputRef" x-value="internalValue" />

Parent can access exposed methods:

<script>
const childRef = ref()

function focusChild() {
  childRef.value?.focus()
}
</script>

<MyInput @ref="childRef" />
<button @click="focusChild">Focus Input</button>

DevTools

Debug and inspect STX applications with built-in developer tools.

Enabling DevTools

<script>
import { enableDevTools } from 'stx'

// Enable in development
if (process.env.NODE_ENV === 'development') {
  enableDevTools({
    maxEvents: 1000,      // Max events in timeline
    maxPerformance: 500   // Max performance records
  })
}
</script>

DevTools Panel

When enabled, a DevTools panel appears in the bottom-right corner with tabs:

  • Components - Component tree with props and state
  • Stores - Store state and mutation history
  • Events - Event timeline

Component Registration

Components are automatically tracked when using registerComponent:

import { registerComponent, updateComponentState } from 'stx'

// Register a component
const componentId = registerComponent(
  'MyComponent',        // Component name
  element,              // DOM element
  { title: 'Hello' },   // Props
  { count: 0 },         // State
  'src/MyComponent.stx' // File path (optional)
)

// Update state
updateComponentState(componentId, { count: 1 })

Store Integration

Stores are automatically tracked when using defineStore:

import { registerStore, recordStoreMutation } from 'stx'

// Register a store
registerStore('userStore', { name: '', email: '' }, {}, ['login', 'logout'])

// Record mutations
recordStoreMutation('userStore', 'name', '', 'John', 'state')

Console API

Access DevTools from the browser console:

// Get all components
__STX_DEVTOOLS__.getComponents()

// Get specific component
__STX_DEVTOOLS__.getComponent('stx-1')

// Log component tree
__STX_DEVTOOLS__.logComponentTree()

// Get all stores
__STX_DEVTOOLS__.getStores()

// Log store state
__STX_DEVTOOLS__.logStoreState('userStore')

// Get event timeline
__STX_DEVTOOLS__.getEvents()

// Get performance metrics
__STX_DEVTOOLS__.getPerformance()

Component Inspection

// Select and highlight a component
__STX_DEVTOOLS__.selectComponent('stx-1')

// Inspect element to find its component
__STX_DEVTOOLS__.inspectElement(document.querySelector('.my-component'))

// Highlight without selecting
__STX_DEVTOOLS__.highlightComponent('stx-1')

// Clear highlight
__STX_DEVTOOLS__.clearHighlight()

Time Travel Debugging

Navigate through store mutation history:

// View store history
const store = __STX_DEVTOOLS__.getStore('userStore')
console.log(store.history)

// Time travel to previous state
__STX_DEVTOOLS__.timeTravel(5, 'userStore') // Go to index 5 in history

Performance Measurement

import { measurePerformance, measurePerformanceAsync } from 'stx'

// Measure sync function
const result = measurePerformance('heavyCalculation', () => {
  return computeExpensiveValue()
}, componentId)

// Measure async function
const data = await measurePerformanceAsync('fetchData', async () => {
  return await fetch('/api/data').then(r => r.json())
}, componentId)

Custom Events

Record custom events in the timeline:

import { recordEvent } from 'stx'

recordEvent({
  timestamp: Date.now(),
  type: 'user:action',
  componentId: 'stx-1',
  payload: { action: 'clicked', target: 'submit-button' }
})

Keyboard Shortcuts

When DevTools panel is open:

KeyAction
Click headerToggle panel open/closed
Click componentSelect and highlight component

Disabling DevTools

import { disableDevTools } from 'stx'

// Disable DevTools (e.g., in production)
disableDevTools()

Best Practices

  1. Only enable in development - DevTools adds overhead
  2. Use component names - Makes debugging easier
  3. Register stores - For full state visibility
  4. Record custom events - Track user interactions
  5. Use performance measurement - Find bottlenecks

Custom Directives API

Create your own directives to extend STX's template syntax.

Define a Simple Directive

import { defineDirective } from 'stx'

// Transform directive - modifies content
const uppercase = defineDirective({
  name: 'uppercase',
  description: 'Convert content to uppercase',
  transform: (content) => content.toUpperCase()
})

// Usage: @uppercase Hello World @enduppercase
// Output: HELLO WORLD

Directive with Parameters

const highlight = defineDirective({
  name: 'highlight',
  defaults: { color: 'yellow', padding: '0.2em' },
  transform: (content, { color, padding }) =>
    `<mark style="background: ${color}; padding: ${padding}">${content}</mark>`
})

// Usage: @highlight(color: 'lime') Important text @endhighlight

Directive with Validation

const truncate = defineDirective({
  name: 'truncate',
  defaults: { length: 100, suffix: '...' },
  validate: ({ length }) => {
    if (length <= 0) return 'Length must be positive'
    return true
  },
  transform: (content, { length, suffix }) =>
    content.length > length
      ? content.slice(0, length) + suffix
      : content
})

Client-Side Directive

const tooltip = defineDirective({
  name: 'tooltip',
  transform: (content, { text }) =>
    `<span class="tooltip" data-tip="${text}">${content}</span>`,
  css: () => `
    .tooltip { position: relative; cursor: help; }
    .tooltip::after {
      content: attr(data-tip);
      position: absolute;
      bottom: 100%;
      background: #333;
      color: white;
      padding: 4px 8px;
      border-radius: 4px;
      opacity: 0;
      transition: opacity 0.2s;
    }
    .tooltip:hover::after { opacity: 1; }
  `,
  client: {
    mounted(el, { value }) {
      el.setAttribute('data-tip', value)
    },
    updated(el, { value }) {
      el.setAttribute('data-tip', value)
    }
  }
})

Built-in Directives

STX includes several built-in directives:

DirectiveDescriptionExample
@uppercaseConvert to uppercase@uppercase text @enduppercase
@lowercaseConvert to lowercase@lowercase TEXT @endlowercase
@capitalizeCapitalize first letter@capitalize hello @endcapitalize
@trimRemove whitespace@trim text @endtrim
@truncate(length, suffix)Truncate text@truncate(50, '...') long text @endtruncate
@highlight(color)Highlight text@highlight(color: 'yellow') text @endhighlight
@badge(variant)Create a badge@badge(variant: 'success') New @endbadge
@codeInline code style@code const x = 1 @endcode
@tooltip(text)Add tooltip@tooltip(text: 'Help') Hover me @endtooltip
@clipboardCopy button@clipboard npm install stx @endclipboard
@currency(amount, currency)Format currency@currency(amount: 99.99, currency: 'USD')
@number(value, decimals)Format number@number(value: 1234.5, decimals: 2)
@pluralize(count, singular)Pluralize@pluralize(count: 5, singular: 'item')

Register Custom Directive

import { registerDirective } from 'stx'

registerDirective({
  name: 'myDirective',
  hasEndTag: true,
  transform: (content, params, context) => {
    // Access template context
    const user = context.user
    return `<div data-user="${user?.name}">${content}</div>`
  }
})

Plugin System

Extend STX with reusable plugins.

Define a Plugin

import { definePlugin } from 'stx'

const myPlugin = definePlugin({
  name: 'my-plugin',
  version: '1.0.0',
  description: 'My awesome plugin',

  // Called when plugin is registered
  onRegister(options) {
    console.log('Plugin registered with options:', options)
  },

  // Add directives
  registerDirectives() {
    return [
      {
        name: 'myDirective',
        handler: (content) => content.toUpperCase(),
        hasEndTag: true
      }
    ]
  },

  // Add expression filters
  registerFilters() {
    return {
      reverse: (value) => String(value).split('').reverse().join('')
    }
  },

  // Lifecycle hooks
  beforeProcess({ template, context }) {
    context.pluginData = { initialized: true }
    return { context }
  },

  afterRender({ template }) {
    return { template: template + '<!-- Rendered by my-plugin -->' }
  }
})

Use a Plugin

import { pluginManager } from 'stx'

// Register plugin
await pluginManager.register(myPlugin, stxOptions)

// Check if registered
pluginManager.has('my-plugin') // true

// Get plugin
const plugin = pluginManager.get('my-plugin')

// Unregister
await pluginManager.unregister('my-plugin')

Plugin Lifecycle Hooks

HookWhen CalledUse Case
onRegisterPlugin is registeredInitialize state
beforeProcessBefore template processingAdd global variables
afterParseAfter parsing, before directivesTemplate analysis
beforeDirectiveBefore each directive phaseModify behavior
afterDirectiveAfter each directive phasePost-process
beforeRenderBefore final outputLast modifications
afterRenderAfter final outputAdd wrappers/scripts
onErrorOn processing errorError recovery
onUnregisterPlugin is removedCleanup

Built-in Plugins

import { debugPlugin, timingPlugin, createVariablesPlugin } from 'stx'

// Debug plugin - logs lifecycle events
await pluginManager.register(debugPlugin, options)

// Timing plugin - measures processing time
await pluginManager.register(timingPlugin, options)

// Variables plugin - adds global variables
const varsPlugin = createVariablesPlugin({
  appName: 'My App',
  version: '1.0.0'
})
await pluginManager.register(varsPlugin, options)

Plugin Dependencies

const childPlugin = definePlugin({
  name: 'child-plugin',
  dependencies: ['parent-plugin'], // Must be registered first

  onRegister() {
    // parent-plugin is guaranteed to be available
  }
})

Create Directive Plugin

import { createDirectivePlugin } from 'stx'

const textPlugin = createDirectivePlugin('text-utils', [
  {
    name: 'reverse',
    handler: (content) => content.split('').reverse().join(''),
    hasEndTag: true
  },
  {
    name: 'repeat',
    handler: (content, [times]) => content.repeat(Number(times) || 1),
    hasEndTag: true
  }
])

Testing Utilities

Test STX components with a Vue Test Utils-like API.

Render a Template

import { render } from 'stx/testing'

const { html, container } = await render('<h1>{{ title }}</h1>', {
  context: { title: 'Hello World' }
})

expect(html).toContain('Hello World')
expect(container.querySelector('h1')?.textContent).toBe('Hello World')

Mount a Component

import { mount } from 'stx/testing'

const wrapper = await mount('<Button @click="onClick">Click me</Button>', {
  props: { disabled: false },
  context: { onClick: vi.fn() }
})

// Query elements
expect(wrapper.find('button')).toBeTruthy()
expect(wrapper.text()).toContain('Click me')

// Check attributes
expect(wrapper.attributes('disabled')).toBeNull()
expect(wrapper.classes()).not.toContain('disabled')

Fire Events

import { mount, fireEvent } from 'stx/testing'

const wrapper = await mount('<input type="text" @input="onInput" />', {
  context: { onInput: vi.fn() }
})

// Fire input event
await fireEvent.input(wrapper.find('input'), 'hello')

// Fire click event
await fireEvent.click(wrapper.find('button'))

// Fire keyboard event
await fireEvent.keyDown(wrapper.find('input'), 'Enter')

// Fire custom event
await fireEvent.custom(wrapper.element, 'my-event', { detail: { foo: 'bar' } })

Check Emitted Events

const wrapper = await mount('<Button @click="$emit(\'clicked\', data)" />')

await wrapper.trigger('click')

// Check if event was emitted
expect(wrapper.emitted('clicked')).toBeDefined()
expect(wrapper.emitted('clicked')).toHaveLength(1)

// Check event payload
const [[payload]] = wrapper.emitted('clicked')!
expect(payload).toEqual({ data: 'value' })

Set Props and Data

const wrapper = await mount('<Counter :count="count" />', {
  props: { count: 0 }
})

expect(wrapper.text()).toContain('0')

// Update props
await wrapper.setProps({ count: 5 })
expect(wrapper.text()).toContain('5')

// Update data
await wrapper.setData({ internalCount: 10 })

Form Testing

const wrapper = await mount(`
  <form @submit="onSubmit">
    <input name="email" type="email" />
    <input name="remember" type="checkbox" />
    <button type="submit">Submit</button>
  </form>
`)

// Set input value
await wrapper.find('input[name="email"]')?.setValue('test@example.com')

// Check checkbox
await wrapper.find('input[name="remember"]')?.setValue(true)

// Submit form
await fireEvent.submit(wrapper.find('form'))

Wait for Conditions

import { waitFor, waitForElement, flushPromises } from 'stx/testing'

// Wait for element to appear
const element = await waitForElement(wrapper, '.loaded', { timeout: 1000 })

// Wait for condition
await waitFor(() => wrapper.text().includes('Loaded'))

// Flush pending promises
await flushPromises()

// Wait for next tick
await wrapper.vm.$nextTick()

Mock Functions

import { createMockFn } from 'stx/testing'

const mockFn = createMockFn()

mockFn('arg1', 'arg2')

expect(mockFn.mock.calls).toHaveLength(1)
expect(mockFn.mock.calls[0]).toEqual(['arg1', 'arg2'])

// With implementation
mockFn.mockImplementation((a, b) => a + b)
expect(mockFn(1, 2)).toBe(3)

// With return value
mockFn.mockReturnValue(42)
expect(mockFn()).toBe(42)

Custom Matchers

import { matchers } from 'stx/testing'

// Check text content
expect(matchers.toContainText(wrapper, 'Hello')).toBe(true)

// Check class
expect(matchers.toHaveClass(wrapper, 'active')).toBe(true)

// Check attribute
expect(matchers.toHaveAttribute(wrapper, 'disabled')).toBe(false)
expect(matchers.toHaveAttribute(wrapper, 'type', 'button')).toBe(true)

// Check visibility
expect(matchers.toBeVisible(wrapper)).toBe(true)

// Check existence
expect(matchers.toExist(wrapper.find('.item'))).toBe(true)

// Check emitted events
expect(matchers.toHaveEmitted(wrapper, 'click')).toBe(true)
expect(matchers.toHaveEmitted(wrapper, 'click', 2)).toBe(true) // emitted twice
expect(matchers.toHaveEmittedWith(wrapper, 'submit', { data: 'value' })).toBe(true)

Test Setup and Cleanup

import { createTestContext, cleanup } from 'stx/testing'

describe('MyComponent', () => {
  let ctx: ReturnType<typeof createTestContext>

  beforeEach(() => {
    ctx = createTestContext({
      props: { title: 'Test' },
      context: { user: { name: 'John' } },
      mocks: { fetchData: vi.fn() }
    })
  })

  afterEach(() => {
    cleanup()
  })

  it('works', async () => {
    const wrapper = await mount('<MyComponent :title="title" />', ctx)
    // ...
  })
})

Snapshot Testing

it('matches snapshot', async () => {
  const wrapper = await mount('<Card title="Hello" />')
  expect(wrapper.html()).toMatchSnapshot()
})

Deferred Loading (@defer)

Lazy load content based on various triggers for better performance.

Basic Usage

@defer(on: 'visible')
  <HeavyComponent />
@placeholder
  <Skeleton />
@loading
  <Spinner />
@error
  <div>Failed to load</div>
@enddefer

Triggers

TriggerDescription
visibleLoads when element enters viewport (IntersectionObserver)
idleLoads when browser is idle (requestIdleCallback)
interactionLoads on first click, focus, or touch
hoverLoads on mouse hover or focus
timer(ms)Loads after specified milliseconds
immediateLoads immediately but async (default)

Examples

<!-- Load on scroll into view -->
@defer(on: 'visible')
  <ImageGallery :images="images" />
@placeholder
  <div class="skeleton h-64"></div>
@enddefer

<!-- Load on hover (prefetch-like) -->
@defer(on: 'hover')
  <PreviewCard :data="data" />
@placeholder
  <span>Hover to preview</span>
@enddefer

<!-- Load after 2 seconds -->
@defer(on: 'timer(2000)')
  <AnalyticsWidget />
@loading
  <div class="spinner"></div>
@enddefer

<!-- Load when browser is idle -->
@defer(on: 'idle')
  <RecommendationsPanel />
@enddefer

Teleport (@teleport)

Move content to a different location in the DOM. Useful for modals, tooltips, and notifications that need to escape their parent's stacking context.

Basic Usage

@teleport('#modals')
  <div class="modal">
    <h2>Modal Title</h2>
    <p>Modal content here</p>
  </div>
@endteleport

<!-- Target element somewhere in the document -->
<div id="modals"></div>

With Disabled State

@teleport('#modals', disabled: isInline)
  <div class="modal">
    Renders inline when isInline is true
  </div>
@endteleport

Common Patterns

<!-- Modal with teleport -->
<button @click="showModal = true">Open Modal</button>

@teleport('#modal-container')
  <div class="modal-overlay" x-show="showModal">
    <div class="modal">
      <h2>{{ modalTitle }}</h2>
      <slot />
      <button @click="showModal = false">Close</button>
    </div>
  </div>
@endteleport

<!-- Tooltip teleported to body -->
@teleport('body')
  <div class="tooltip" x-style="tooltipPosition">
    {{ tooltipText }}
  </div>
@endteleport

<!-- Notification toast -->
@teleport('#notifications')
  <div class="toast toast-success">
    {{ message }}
  </div>
@endteleport

Events

// Listen for teleport completion
document.addEventListener('teleport:mounted', (e) => {
  console.log('Teleported from:', e.detail.sourceId);
  console.log('Teleported to:', e.detail.target);
});

Static Site Generation (SSG)

Generate static HTML pages at build time with optional Incremental Static Regeneration (ISR).

generateStaticSite()

Build all pages in a directory to static HTML:

import { generateStaticSite } from '@stacksjs/stx'

const result = await generateStaticSite({
  pagesDir: 'pages',
  outputDir: 'dist',
  publicDir: 'public',
  domain: 'https://example.com',
  sitemap: true,
  generate404: true,
  cache: true,
  parallel: 4,
  cleanOutput: true,
  hooks: {
    onBuildStart: () => console.log('Build starting...'),
    onBuildEnd: (result) => console.log(`Built ${result.totalPages} pages`),
    onPageStart: (page) => console.log(`Building ${page}...`),
    onPageEnd: (page, html) => console.log(`Built ${page}`)
  }
})

console.log(`Built ${result.successCount}/${result.totalPages} pages`)
console.log(`Build time: ${result.buildTime}ms`)

defineStaticPaths()

Define dynamic routes for static generation (Next.js-style):

import { defineStaticPaths } from '@stacksjs/stx'

// In pages/blog/[slug].stx
export const getStaticPaths = defineStaticPaths(async () => {
  const posts = await fetchAllPosts()

  return posts.map(post => ({
    params: { slug: post.slug },
    props: { title: post.title, content: post.content }
  }))
})

// Sync version
export const getStaticPaths = defineStaticPaths(() => [
  { params: { slug: 'hello-world' } },
  { params: { slug: 'getting-started' } }
])

Static path discovery failures

SSG distinguishes a missing getStaticPaths (warning) from a hook that intentionally returns no paths (informational). Parse/extraction errors, thrown exceptions, rejected promises and malformed results are build failures: SSGResult.failedCount/errors and hooks.onError identify the route, template file, phase and original error. The build CLI exits non-zero for these failures. Other pages may still be written; do not deploy an output from a failed build. An unknown dynamic page count is recorded as one failed route, not an invented number of missing pages.

Function declarations (including nested blocks and TypeScript), const/arrow hooks and defineStaticPaths(...) initializers are supported. Return { paths: [...] } or a path array; each entry needs a params object. As before, hooks are evaluated independently of other page server declarations: this does not add resolution of imported or module-local closure dependencies.

createISRHandler()

On-demand revalidation for static pages:

import { createISRHandler } from '@stacksjs/stx'

const isr = createISRHandler({
  pagesDir: 'pages',
  cacheDir: '.isr-cache',
  revalidate: 60 // seconds
})

// Get cached page
const html = await isr.get('/blog/my-post')

// Revalidate specific route
await isr.revalidate('/blog/my-post')

// Invalidate routes matching pattern
await isr.invalidate(/^\/blog\//)

createMarkdownLoader()

Load markdown files with frontmatter:

import { createMarkdownLoader } from '@stacksjs/stx'

const loader = createMarkdownLoader()
const result = await loader.load('posts/hello.md')

console.log(result.data)     // Frontmatter: { title, date, tags }
console.log(result.content)  // Rendered HTML
console.log(result.toc)      // Table of contents

SSG Options

OptionTypeDefaultDescription
pagesDirstring'pages'Directory containing page templates
outputDirstring'dist'Output directory for generated files
publicDirstring'public'Static assets directory
domainstring-Domain for sitemap URLs
sitemapbooleantrueGenerate sitemap.xml
rssbooleanfalseGenerate RSS feed
generate404booleantrueGenerate 404.html
cachebooleantrueEnable build caching
parallelnumber4Parallel page generation
cleanOutputbooleanfalseClean output dir before build

Visual Testing

Snapshot and visual regression testing for STX components.

createSnapshotTester()

Create HTML snapshots for component testing:

import { createSnapshotTester } from '@stacksjs/stx'

const tester = createSnapshotTester({
  snapshotDir: '__snapshots__',
  updateSnapshots: false,
  normalizeHtml: true
})

// Create or compare snapshot
const result = await tester.test('Button', '<button class="btn">Click</button>')

if (result.passed) {
  console.log('Snapshot matches!')
} else {
  console.log('Diff:', result.diff)
}

// Update snapshot
await tester.update('Button', '<button class="btn">Click</button>')

createVisualRegressionTester()

Visual diff testing with multiple viewports:

import { createVisualRegressionTester } from '@stacksjs/stx'

const tester = createVisualRegressionTester({
  snapshotDir: '__visual__',
  viewports: [
    { name: 'mobile', width: 375, height: 667 },
    { name: 'tablet', width: 768, height: 1024 },
    { name: 'desktop', width: 1920, height: 1080 }
  ],
  threshold: 0.1
})

// Test across all viewports
const results = await tester.testAllViewports('Card', '<div class="card">...</div>')

results.forEach(result => {
  console.log(`${result.viewport}: ${result.passed ? 'PASS' : 'FAIL'}`)
})

createStoryTester()

Story-based testing (Storybook-style):

import { createStoryTester } from '@stacksjs/stx'

const storyTester = createStoryTester({
  snapshotDir: '__stories__'
})

// Define component stories
storyTester.addStory('Button', 'primary', '<button class="btn-primary">Primary</button>')
storyTester.addStory('Button', 'secondary', '<button class="btn-secondary">Secondary</button>')
storyTester.addStory('Button', 'disabled', '<button class="btn" disabled>Disabled</button>')

// Test all stories
const results = await storyTester.testAll()

// Generate HTML report
const report = await generateReport(results, {
  title: 'Component Stories',
  outputPath: 'test-report.html'
})

snapshot()

Simple snapshot utility:

import { snapshot } from '@stacksjs/stx'

// Quick snapshot test
const result = await snapshot('my-component', '<div>Content</div>', {
  snapshotDir: '__snapshots__'
})

expect(result.passed).toBe(true)

generateReport()

Generate HTML test report:

import { generateReport } from '@stacksjs/stx'

const report = await generateReport(testResults, {
  title: 'Visual Test Report',
  outputPath: 'report.html',
  includeTimestamp: true
})

Hydration Runtime

Client-side hydration for server-rendered pages.

hydrate()

Hydrate a server-rendered page:

import { hydrate } from '@stacksjs/stx'

// Basic hydration
await hydrate()

// With options
await hydrate({
  strategy: 'eager',  // 'eager' | 'lazy' | 'idle' | 'visible'
  rootElement: document.getElementById('app'),
  onHydrated: () => console.log('Hydration complete!')
})

Hydration Strategies

StrategyDescription
eagerHydrate immediately on page load
lazyHydrate on first user interaction
idleHydrate when browser is idle
visibleHydrate when element enters viewport

getHydrationRuntime()

Get the client-side hydration runtime script:

import { getHydrationRuntime } from '@stacksjs/stx'

const runtime = getHydrationRuntime({
  strategy: 'visible',
  rootMargin: '100px'
})

// Inject into page
const html = `
<html>
<body>
  ${content}
  <script>${runtime}</script>
</body>
</html>
`

generateHydrationScript()

Generate hydration bootstrap script:

import { generateHydrationScript } from '@stacksjs/stx'

const script = generateHydrationScript({
  state: { user: { name: 'John' } },
  components: ['Header', 'Footer'],
  strategy: 'eager'
})

Selective Hydration

Hydrate specific components:

import { hydrateComponent, hydrateIsland } from '@stacksjs/stx'

// Hydrate single component
await hydrateComponent('interactive-widget', {
  strategy: 'visible'
})

// Hydrate island by ID
await hydrateIsland('island-abc123')

Component HMR

Hot Module Replacement with state preservation for STX components.

getHMRHandler()

Get the server-side HMR handler:

import { getHMRHandler } from '@stacksjs/stx'

const hmr = getHMRHandler({
  watchDir: 'components',
  extensions: ['.stx'],
  debounce: 100
})

// Handle file changes
hmr.on('change', async (file) => {
  const update = await hmr.getUpdate(file)
  // Send to connected clients
  broadcast(update)
})

// Start watching
hmr.start()

generateHMRClientScript()

Generate client-side HMR script:

import { generateHMRClientScript } from '@stacksjs/stx'

const script = generateHMRClientScript({
  wsUrl: 'ws://localhost:3000/__hmr',
  reconnectInterval: 1000,
  overlay: true
})

// Inject into development pages
const devHtml = `${html}<script>${script}</script>`

HMR Client API

// In browser console or client code
window.__STX_HMR__.on('update', (update) => {
  console.log('Component updated:', update.component)
})

// Manually trigger reload
window.__STX_HMR__.reload('Header')

// Preserve state during updates
window.__STX_HMR__.preserveState('counter', { count: 5 })

HMR Options

OptionTypeDefaultDescription
watchDirstring'components'Directory to watch
extensionsstring[]['.stx']File extensions to watch
debouncenumber100Debounce delay in ms
preserveStatebooleantruePreserve component state
overlaybooleantrueShow error overlay

Development Server Integration

import { createDevServer } from '@stacksjs/stx'

const server = await createDevServer({
  port: 3000,
  hmr: true,
  open: true
})

// HMR is automatically enabled
// Changes to .stx files trigger hot updates

Deployment

Deploy your STX application to Netlify with a single command.

CLI Commands

# Deploy to Netlify (draft preview)
stx deploy

# Deploy to production
stx deploy --production

# Deploy with custom build directory
stx deploy dist --site-id my-site-abc123

# Initialize Netlify configuration
stx deploy --init

# Preview deployment without uploading
stx deploy --dry-run

# Skip build step
stx deploy --no-build

deploy()

Programmatic deployment API:

import { deploy } from '@stacksjs/stx'

const result = await deploy({
  directory: 'dist',
  siteId: 'my-site-id',      // or NETLIFY_SITE_ID env var
  token: 'netlify-token',     // or NETLIFY_AUTH_TOKEN env var
  production: true,           // Deploy to production (default: draft)
  message: 'Release v1.0.0',  // Deploy title
  open: true,                 // Open URL in browser
  dryRun: false,              // Preview mode
  onProgress: (status) => {
    console.log(status.stage, status.message)
  },
})

console.log('Deployed:', result.url)
console.log('Deploy ID:', result.deployId)

initNetlify()

Initialize Netlify configuration:

import { initNetlify } from '@stacksjs/stx'

await initNetlify({
  directory: process.cwd(),
  siteId: 'my-site-id',  // Saves to .env.local
})

generateNetlifyToml()

Generate netlify.toml configuration:

import { generateNetlifyToml, createDefaultNetlifyConfig } from '@stacksjs/stx'

const config = createDefaultNetlifyConfig({
  buildCommand: 'bun run build',
  outputDir: 'dist',
  functionsDir: 'functions',
})

const toml = generateNetlifyToml(config)
// Write to netlify.toml

Environment Variables

VariablePurpose
NETLIFY_AUTH_TOKENAPI authentication token
NETLIFY_SITE_IDDefault site ID for deployments

Deploy Result

interface DeployResult {
  success: boolean
  url: string             // Deploy preview URL
  siteUrl?: string        // Production URL (if --production)
  deployId: string
  logs: string[]
  duration: number        // Build time in ms
  filesUploaded: number
  totalSize: number       // Bytes uploaded
}

Image Optimization

STX provides comprehensive image optimization for improved Core Web Vitals (LCP).

Image Component

<!-- Basic usage -->
<Image
  src="/images/hero.jpg"
  alt="Hero image"
  width="1200"
  height="600"
/>

<!-- With responsive sizes -->
<Image
  src="/images/hero.jpg"
  alt="Hero image"
  width="1200"
  height="600"
  sizes="(max-width: 768px) 100vw, 50vw"
/>

<!-- Priority image (above-fold) -->
<Image
  src="/images/hero.jpg"
  alt="Hero image"
  priority
/>

<!-- With placeholder -->
<Image
  src="/images/hero.jpg"
  alt="Hero image"
  placeholder="blur"
/>

Image Component Props

PropTypeDefaultDescription
srcstringrequiredSource image path
altstringrequiredAlt text (accessibility)
widthnumber-Display width
heightnumber-Display height
sizesstring100vwResponsive sizes attribute
loading'lazy' | 'eager''lazy'Loading strategy
prioritybooleanfalsePreload (above-fold images)
qualitynumber80Output quality (1-100)
placeholder'blur' | 'color' | 'none''none'Placeholder type
formatsstring[]['webp', 'jpeg']Output formats

@image Directive

<!-- Basic usage -->
@image('/images/hero.jpg', 'Hero image')

<!-- With options -->
@image('/images/hero.jpg', 'Hero image', { width: 800, quality: 85 })

<!-- Lazy loading -->
@image('/images/photo.jpg', 'Photo', { lazy: true })

<!-- High priority -->
@image('/images/banner.jpg', 'Banner', { priority: true })

CLI Command

# Optimize all images in a directory
stx images public/images

# Specify output directory
stx images public/images -o dist/images

# Custom widths
stx images public/images --widths 640,1024,1920

# Custom formats
stx images public/images --formats webp,avif

# Set quality
stx images public/images --quality 85

# Preview without processing
stx images public/images --dry-run

# Verbose output
stx images public/images --verbose

Build Plugin

import { createImagePlugin } from '@stacksjs/stx'

// Add to production build config
const plugin = createImagePlugin({
  // Input directories to scan
  inputDirs: ['public', 'src/assets'],

  // Output directory
  outputDir: 'dist/images',

  // Base URL for generated paths
  baseUrl: '/images',

  // Output formats
  formats: ['webp', 'avif', 'jpeg'],

  // Responsive widths
  widths: [320, 640, 1024, 1920],

  // Quality (1-100)
  quality: 80,

  // Placeholder generation
  placeholder: 'blur',

  // Concurrent processing
  concurrency: 4,

  // Cache processed images
  cache: true,

  // Verbose logging
  verbose: false,
})

Programmatic API

import {
  processImage,
  getImageMetadata,
  isSharpAvailable,
  generateSrcSet,
  generateSizes,
  optimizeDirectory,
} from '@stacksjs/stx'

// Check if sharp is available
const hasSharp = await isSharpAvailable()

// Get image metadata
const metadata = await getImageMetadata('/path/to/image.jpg')
console.log(metadata.width, metadata.height, metadata.format)

// Process single image
const result = await processImage('/path/to/image.jpg', {
  widths: [320, 640, 1024],
  formats: ['webp', 'jpeg'],
  quality: 80,
  outputDir: 'dist/images',
})

console.log(result.variants)  // Generated variants
console.log(result.placeholder)  // Placeholder data URL

// Generate srcset from variants
const srcset = generateSrcSet(result.variants, 'webp')
// "/images/test-320.webp 320w, /images/test-640.webp 640w, ..."

// Generate sizes attribute
const sizes = generateSizes({
  '768px': '100vw',
  '1024px': '50vw',
})
// "(max-width: 1024px) 50vw, (max-width: 768px) 100vw, 100vw"

// Optimize entire directory
const dirResult = await optimizeDirectory('public/images', 'dist/images', {
  formats: ['webp', 'jpeg'],
  widths: [640, 1024, 1920],
  quality: 85,
  verbose: true,
})

console.log(dirResult.stats.optimizedImages)
console.log(dirResult.stats.variants)

Configuration

Add to stx.config.ts:

export default {
  images: {
    // Enable image optimization
    enabled: true,

    // Default widths for responsive images
    widths: [320, 640, 768, 1024, 1280, 1536, 1920],

    // Default output formats
    formats: ['webp', 'jpeg'],

    // Default quality (1-100)
    quality: 80,

    // Enable during development (slower but tests optimization)
    devOptimize: false,

    // Cache directory
    cacheDir: '.stx/image-cache',

    // Placeholder generation
    placeholder: 'none',  // 'blur' | 'dominant-color' | 'none'
  },
}

Output

The Image component generates a <picture> element with multiple sources:

<picture>
  <source
    type="image/avif"
    srcset="/images/hero-320-abc123.avif 320w, /images/hero-640-abc123.avif 640w, ..."
    sizes="(max-width: 768px) 100vw, 50vw"
  />
  <source
    type="image/webp"
    srcset="/images/hero-320-abc123.webp 320w, /images/hero-640-abc123.webp 640w, ..."
    sizes="(max-width: 768px) 100vw, 50vw"
  />
  <source
    type="image/jpeg"
    srcset="/images/hero-320-abc123.jpg 320w, /images/hero-640-abc123.jpg 640w, ..."
    sizes="(max-width: 768px) 100vw, 50vw"
  />
  <img
    src="/images/hero-1024-abc123.jpg"
    alt="Hero image"
    width="1200"
    height="600"
    loading="lazy"
    decoding="async"
    style="aspect-ratio: 2"
  />
</picture>

Requirements

Image optimization requires sharp:

bun add sharp

Without sharp, images are served without optimization (graceful degradation).


Suggested Future Syntax

The following are proposed additions that align with STX's design philosophy:

Async Data Fetching

<!-- Declarative data fetching -->
@fetch(url="/api/users" as="users")
  @foreach (users as user)
    <UserCard :user="user" />
  @endforeach
@endfetch

<!-- With loading/error states -->
@fetch(url="/api/posts" as="posts")
  @loading
    <Spinner />
  @endloading

  @error
    <ErrorMessage :error="error" />
  @enderror

  @success
    <PostList :posts="posts" />
  @endsuccess
@endfetch

Transitions & Animations

<!-- Declarative transitions -->
@transition('fade')
  <Modal :visible="showModal" />
@endtransition

<!-- Named transitions -->
@transition('slide-up', { duration: 300 })
  <Notification :show="hasNotification" />
@endtransition

Deferred Loading

<!-- Lazy load component -->
@defer
  <HeavyComponent />
@placeholder
  <Skeleton />
@enddefer

<!-- With conditions -->
@defer(on="visible")
  <BelowFoldContent />
@enddefer

@defer(on="idle")
  <Analytics />
@enddefer

@defer(on="interaction")
  <Comments />
@enddefer

Suspense Boundaries

@suspense
  <AsyncComponent />
@fallback
  <Loading />
@endsuspense

Portals / Teleport

<!-- Render content elsewhere in DOM -->
@teleport('#modal-container')
  <Modal :show="isOpen">
    <p>Modal content</p>
  </Modal>
@endteleport

Fragments

<!-- Return multiple root elements -->
@fragment
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
@endfragment

Memoization

<!-- Cache expensive renders -->
@memo(key="user-{{ user.id }}")
  <ExpensiveUserProfile :user="user" />
@endmemo

Error Boundaries

@catch
  <RiskyComponent />
@error
  <p>Something went wrong: {{ error.message }}</p>
@endcatch

Debug Helpers

<!-- Development only -->
@debug(user)
<!-- Outputs formatted variable dump -->

@breakpoint
<!-- Pauses execution in dev tools -->

Streaming / Partial Hydration

<!-- Stream content as it becomes available -->
@stream
  <SlowComponent />
@endstream

<!-- Selective hydration -->
@hydrate(on="visible")
  <InteractiveWidget />
@endhydrate

@static
  <StaticContent />
@endstatic

Type-Safe Props

<script server>
// TypeScript-style prop definitions
interface Props {
  title: string
  count?: number
  items: Array<{ id: number; name: string }>
}

const { title, count = 0, items }: Props = props
</script>

Inline Styles with Variables

@style(vars={ primaryColor: theme.primary, spacing: '1rem' })
  .card {
    background: var(--primaryColor);
    padding: var(--spacing);
  }
@endstyle

Content Security

<!-- Sanitize user content -->
@safe(userContent)

<!-- Trust content explicitly -->
@trusted(adminContent)

CLI Commands

# Development server
stx dev [directory] --port 3000

# Build for production
stx build

# Preview production build
stx preview

# Initialize new project
stx init

# Generate documentation
stx docs --format html

# Icon management
stx iconify list
stx iconify generate <collection>

File-Based Routing

STX uses file-based routing in the pages/ directory:

FileRoute
pages/index.stx/
pages/about.stx/about
pages/blog/index.stx/blog
pages/blog/[slug].stx/blog/:slug (dynamic)
pages/users/[id]/posts.stx/users/:id/posts
pages/[...catchAll].stx/* (catch-all)

Virtual Scrolling

Efficiently render large lists using windowing/virtualization.

@virtualList

Renders only visible items for optimal performance:

<script server>
const items = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`,
  description: `Description for item ${i}`
}))
</script>

@virtualList(items, { itemHeight: 50, containerHeight: 400, overscan: 3 })
  <div class="item">
    <strong>{{ item.name }}</strong>
    <p>{{ item.description }}</p>
  </div>
@endvirtualList

@virtualGrid

Grid virtualization for image galleries or card layouts:

@virtualGrid(products, { columns: 4, rowHeight: 200, containerHeight: 600, gap: 16 })
  <div class="product-card">
    <img src="{{ item.image }}" alt="{{ item.name }}" />
    <h3>{{ item.name }}</h3>
    <span>${{ item.price }}</span>
  </div>
@endvirtualGrid

@infiniteList

Virtual list with infinite scroll support:

@infiniteList(posts, { itemHeight: 100, containerHeight: 500, threshold: 200 })
  <article class="post">
    <h2>{{ item.title }}</h2>
    <p>{{ item.excerpt }}</p>
  </article>
@loading
  <div class="loading-spinner">Loading more posts...</div>
@endinfiniteList

Virtual List Options

OptionTypeDefaultDescription
itemHeightnumberrequiredHeight of each item in pixels
containerHeightnumberrequiredHeight of the scroll container
overscannumber3Buffer items outside viewport
keyPropertystring'id'Property to use as key
dynamicHeightbooleanfalseEnable variable item heights
containerClassstring''CSS class for container
itemClassstring''CSS class for each item

JavaScript API

import { createVirtualList, useVirtualList } from 'stx'

// Create virtual list instance
const list = createVirtualList(items, {
  itemHeight: 50,
  containerHeight: 400
})

// Get visible items
const { visibleItems, startIndex, endIndex } = list.getState()

// Scroll to specific item
list.scrollToIndex(500)

// Hook for reactive virtual list
const { visibleItems, containerProps, itemProps, scrollToIndex } = useVirtualList(
  () => items,
  { itemHeight: 50, containerHeight: 400 }
)

Partial Hydration (Islands Architecture)

Selectively hydrate interactive components while leaving static content as plain HTML.

Hydration Strategies

@client:load

Hydrate immediately on page load:

@client:load
  <InteractiveWidget />
@endclient

@client:idle

Hydrate when browser is idle (uses requestIdleCallback):

@client:idle(priority: 'high', timeout: 2000)
  <ChatWidget />
@endclient

@client:visible

Hydrate when component enters viewport (uses IntersectionObserver):

@client:visible(rootMargin: '100px', threshold: 0.1)
  <ImageCarousel :images="images" />
@endclient

@client:media

Hydrate when media query matches:

@client:media(media: '(min-width: 768px)')
  <DesktopNavigation />
@endclient

@client:hover

Hydrate on first hover or focus:

@client:hover
  <Tooltip content="More info..." />
@endclient

@client:event

Hydrate on custom event:

@client:event(event: 'user:authenticated')
  <UserDashboard />
@endclient

@client:only

Client-side only rendering (no SSR):

@client:only
  <BrowserOnlyComponent />
@endclient

Static Content

Mark content as static (never hydrates):

@static
  <footer>
    <p>Copyright 2025 My Company</p>
  </footer>
@endstatic

JavaScript API

import {
  hydrateIsland,
  hydrateByStrategy,
  hydrateAll,
  isHydrated,
  onHydrated
} from 'stx'

// Manually hydrate an island
hydrateIsland('island-abc123')

// Hydrate all islands with a specific strategy
hydrateByStrategy('visible')

// Hydrate all pending islands
hydrateAll()

// Check if hydrated
if (isHydrated('island-abc123')) {
  console.log('Island is interactive')
}

// Wait for hydration
await onHydrated('island-abc123')

Computed Properties & Reactivity

Full reactivity system with computed properties and watchers.

ref()

Create reactive references:

import { ref } from 'stx'

const count = ref(0)
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

// Subscribe to changes
const unsubscribe = count.subscribe((newValue) => {
  console.log('Count changed:', newValue)
})

computed()

Create computed properties with automatic dependency tracking:

import { ref, computed } from 'stx'

const firstName = ref('John')
const lastName = ref('Doe')

// Read-only computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
console.log(fullName.value) // 'John Doe'

firstName.value = 'Jane'
console.log(fullName.value) // 'Jane Doe'

// Writable computed
const fullNameWritable = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value) => {
    const [first, last] = value.split(' ')
    firstName.value = first
    lastName.value = last
  }
})

fullNameWritable.value = 'Alice Smith'
console.log(firstName.value) // 'Alice'

watch()

Watch reactive sources:

import { ref, watch } from 'stx'

const count = ref(0)

// Basic watch
const stop = watch(count, (newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`)
})

// With options
watch(count, (newValue) => {
  console.log('Count:', newValue)
}, {
  immediate: true,  // Run immediately
  deep: true,       // Deep watch objects
  once: true,       // Stop after first change
  flush: 'post'     // Timing: 'pre' | 'post' | 'sync'
})

// A getter, or several sources at once
watch(() => user().id, id => loadProfile(id))
watch([from, to], ([start, end], [oldStart, oldEnd]) => reload(start, end))

// Stop watching
stop()

The callback runs only when the value changes (Object.is, or structurally with deep), not at creation unless immediate. The client runtime's watch (a bare watch in <script client>) has the same contract. See the Composables Reference.

watchEffect()

Auto-tracking effect:

import { ref, watchEffect } from 'stx'

const count = ref(0)
const multiplier = ref(2)

// Automatically tracks dependencies
const stop = watchEffect(() => {
  console.log(`Result: ${count.value * multiplier.value}`)
})

count.value = 5      // Logs: "Result: 10"
multiplier.value = 3 // Logs: "Result: 15"

watchMultiple()

Watch multiple sources:

import { ref, watchMultiple } from 'stx'

const firstName = ref('John')
const lastName = ref('Doe')

watchMultiple([firstName, lastName], ([first, last], [oldFirst, oldLast]) => {
  console.log(`Name changed from ${oldFirst} ${oldLast} to ${first} ${last}`)
})

Debounced & Throttled Computed

import { ref, debouncedComputed, throttledComputed } from 'stx'

const searchQuery = ref('')

// Debounced - wait for 300ms of no changes
const debouncedSearch = debouncedComputed(
  () => searchQuery.value.toLowerCase(),
  300
)

// Throttled - update at most every 100ms
const throttledSearch = throttledComputed(
  () => searchQuery.value.toLowerCase(),
  100
)

Template Directives

<!-- Define computed in template -->
@computed(fullName, firstName + ' ' + lastName)

<h1>{{ fullName }}</h1>

<!-- Watch with handler -->
@watch(count)
  function(newValue) {
    console.log('Count is now:', newValue);
  }
@endwatch

Utility Functions

import { isRef, unref, toRefs, shallowRef } from 'stx'

// Check if value is a ref
isRef(count) // true

// Unwrap ref (returns value if ref, otherwise returns as-is)
unref(count) // 0
unref(5)     // 5

// Convert object properties to refs
const state = { count: 0, name: 'test' }
const { count, name } = toRefs(state)

// Shallow ref (doesn't make nested objects reactive)
const data = shallowRef({ nested: { value: 1 } })

Browser Composables

Reusable composable functions for common browser patterns. All composables auto-cleanup when used within a component context.

Storage

import { useLocalStorage, useSessionStorage, useCookie } from 'stx'

// Reactive localStorage with auto-serialization (JSON)
const theme = useLocalStorage('theme', 'dark')
theme()              // read
theme.set('light')   // write — persists to localStorage

// Reactive sessionStorage (cleared when tab closes; JSON-serialized)
const draftCart = useSessionStorage('draft-cart', [])

// Reactive cookie (string-valued; respects cookie attributes; .set('') deletes)
const token = useCookie('auth-token', {
  maxAge: 60 * 60 * 24 * 30,  // 30 days
  sameSite: 'Lax',
})

Signal-shaped storage composables — read via signal(), write via signal.set(...). useLocalStorage and useSessionStorage JSON-serialize values and sync across tabs via the storage event. useCookie is string-valued (cookies are strings); it respects path / domain / maxAge / expires / sameSite / secure / encode / decode options, but does not auto-sync across tabs (cookies don't fire storage events).

Events

import { useEventListener, useClickOutside } from 'stx'

// Window events (auto-cleanup)
useEventListener('resize', () => console.log('resized'))
useEventListener('keydown', (e) => console.log(e.key))

// Element events
useEventListener(buttonRef, 'click', handler)

// Click outside detection (great for dropdowns)
const dropdownRef = ref<HTMLElement>()
useClickOutside(dropdownRef, () => {
  isOpen.value = false
})

Window & Media

import {
  useWindowSize,
  useMediaQuery,
  useBreakpoints,
  usePrefersDark,
  useOnline,
  useMouse,
  useScroll
} from 'stx'

// Window dimensions
const { width, height } = useWindowSize()

// Media queries
const isMobile = useMediaQuery('(max-width: 768px)')
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

// Common breakpoints
const { isMobile, isTablet, isDesktop, isLargeDesktop } = useBreakpoints()

// Dark mode preference
const isDark = usePrefersDark()

// Online/offline status
const isOnline = useOnline()

// Mouse position
const { x, y } = useMouse()

// Scroll position
const { x, y, isScrolling } = useScroll()
const { x, y } = useScroll(containerRef) // For specific element

Timing

import {
  useDebounce,
  useDebouncedValue,
  useThrottle,
  useInterval,
  useTimeout
} from 'stx'

// Debounce function calls
const debouncedSearch = useDebounce((query: string) => {
  fetchResults(query)
}, 300)

// Debounce a reactive value
const debouncedQuery = useDebouncedValue(() => searchInput.value, 300)

// Throttle function calls
const throttledScroll = useThrottle(() => {
  updateHeader()
}, 100)

// Reactive interval
const { counter, pause, resume, reset } = useInterval(1000)

// Timeout with controls
const { isPending, start, stop } = useTimeout(() => {
  showNotification()
}, 5000)

Utilities

import {
  useToggle,
  useCounter,
  useClipboard,
  useTitle,
  useIntersectionObserver,
  useFocus
} from 'stx'

// Boolean toggle
const [isOpen, toggle, setOpen] = useToggle(false)
toggle()        // true
setOpen(false)  // false

// Counter with bounds
const { count, inc, dec, set, reset } = useCounter(0, { min: 0, max: 100 })
inc()      // 1
inc(5)     // 6
dec(2)     // 4

// Clipboard
const { copy, copied, text, read } = useClipboard()
await copy('Hello!')
if (copied.value) showToast('Copied!')

// Document title
const title = useTitle('My App')
title.value = 'New Page - My App'

// Intersection observer (lazy loading, animations)
const { isVisible, stop } = useIntersectionObserver(elementRef, {
  threshold: 0.5
})

// Focus management
const { isFocused, focus, blur } = useFocus(inputRef)

Async

// useFetch, useQuery, useMutation are auto-imported

// Basic fetch with reactive state
const { data, error, loading, refetch } = useFetch('/api/users')

// With options
const { data } = useFetch('/api/user', {
  immediate: false,  // Don't fetch immediately
  initialData: []
})

useQuery (Advanced Data Fetching)

useQuery() provides caching, background revalidation, and polling — ideal for read operations.

// Auto-imported — no import needed

// Basic query with caching
const { data, loading, error, refetch, invalidate, isStale } = useQuery('/api/users', {
  staleTime: 30000,        // Data stays fresh for 30 seconds
  cacheTime: 300000,       // Cache kept for 5 minutes
  refetchOnFocus: true,    // Refetch when tab regains focus
  transform: (raw) => raw.users,
  onSuccess: (users) => console.log('Loaded', users.length, 'users'),
})

// Polling (refetch every 10 seconds)
const { data: liveData } = useQuery('/api/metrics', {
  refetchInterval: 10000,
})

// Invalidate cache to force re-fetch
await invalidate()

useQuery Options

OptionTypeDefaultDescription
staleTimenumber0Time in ms before data is considered stale
cacheTimenumber300000Time in ms to keep data in cache
cacheKeystringURLCustom cache key
refetchOnFocusbooleanfalseRefetch when tab regains focus
refetchIntervalnumber-Polling interval in ms
immediatebooleantrueFetch on mount
transformfunction-Transform response data
headersobject-Request headers
onSuccessfunction-Success callback
onErrorfunction-Error callback

useQuery Return

PropertyTypeDescription
dataSignalReactive data signal
loadingSignalLoading state
errorSignalError message (null if none)
isStaleSignalWhether data is stale
refetch()functionManually re-fetch
invalidate()functionClear cache and re-fetch

useMutation (Write Operations)

useMutation() is for POST/PUT/DELETE with optimistic updates and cache invalidation.

// Auto-imported — no import needed

// Basic mutation
const { data, loading, error, mutate, reset } = useMutation('/api/users', {
  method: 'POST',
  onSuccess: (user) => console.log('Created:', user.name),
  invalidateQueries: ['/api/users'],  // Bust cache on success
})

// Execute the mutation
await mutate({ name: 'Alice', email: 'alice@example.com' })

// Optimistic update (instant UI feedback, rolls back on error)
const { mutate: updateTodo } = useMutation('/api/todos', {
  method: 'PUT',
  optimisticData: (body) => ({ ...body, updatedAt: new Date().toISOString() }),
  invalidateQueries: ['/api/todos'],
})

useMutation Options

OptionTypeDefaultDescription
methodstring'POST'HTTP method
headersobject-Request headers
transformfunction-Transform response
optimisticDataT | function-Optimistic update data
invalidateQueriesstring[]-Cache keys to invalidate on success
onSuccessfunction-Success callback
onErrorfunction-Error callback

useMutation Return

PropertyTypeDescription
dataSignalResponse data
loadingSignalLoading state
errorSignalError message
mutate(body)functionExecute the mutation
reset()functionReset state to initial

Form Validation

Simple, chainable form validation API.

Basic Usage

import { useForm, v } from 'stx'

const form = useForm({
  email: v.required().email(),
  password: v.required().min(8).hasUppercase().hasNumber(),
  age: v.number().between(18, 100)
})

// Access form state
form.values.email       // Current value
form.errors.email       // Array of error messages
form.touched.email      // Has field been touched?
form.isValid            // Is entire form valid?
form.isDirty            // Have any values changed?

// Methods
await form.validate()              // Validate all fields
await form.validateField('email')  // Validate single field
form.reset()                       // Reset to initial values
form.setFieldValue('email', 'test@example.com')
form.setFieldTouched('email', true)

Available Validators

String:

v.required(message?)      // Field is required
v.email(message?)         // Valid email format
v.url(message?)           // Valid URL format
v.min(length, message?)   // Minimum length
v.max(length, message?)   // Maximum length
v.length(min, max, msg?)  // Length between
v.pattern(regex, message) // Match regex pattern
v.alphanumeric(message?)  // Letters and numbers only
v.noSpaces(message?)      // No whitespace
v.startsWithLetter(msg?)  // Must start with letter

Password:

v.hasUppercase(message?)  // Contains uppercase
v.hasLowercase(message?)  // Contains lowercase
v.hasNumber(message?)     // Contains number
v.hasSpecial(message?)    // Contains special char

Number:

v.number(message?)           // Must be numeric
v.integer(message?)          // Whole number only
v.positive(message?)         // Greater than 0
v.negative(message?)         // Less than 0
v.minValue(min, message?)    // Minimum value
v.maxValue(max, message?)    // Maximum value
v.between(min, max, msg?)    // Value in range

Comparison:

v.matches(fieldName, message?)   // Match another field
v.oneOf([values], message?)      // In allowed list
v.notOneOf([values], message?)   // Not in list

Advanced:

// Custom sync validator
v.custom((value) => {
  return value.startsWith('A') || 'Must start with A'
})

// Async validator (API checks)
v.async(async (value) => {
  const exists = await checkUsername(value)
  return exists ? 'Username taken' : true
})

// Conditional validation
v.when(
  (value, formValues) => formValues.country === 'US',
  v.required().pattern(/^\d{5}$/, 'Invalid ZIP')
)

Form Submission

const form = useForm({
  email: v.required().email(),
  password: v.required().min(8)
})

// handleSubmit validates before calling your function
const onSubmit = form.handleSubmit(async (values) => {
  await api.login(values.email, values.password)
})

// In template
<form @submit.prevent="onSubmit">
  ...
</form>

Template Directives

<!-- Single error message -->
@error(form.errors.email)
  <span class="error">{{ message }}</span>
@enderror

<!-- Multiple errors as list -->
@errors(form.errors.password)
  <li>{{ message }}</li>
@enderrors

<!-- Conditional block if has errors -->
@hasErrors(form.errors.email)
  <div class="error-container">
    ...
  </div>
@endhasErrors

Complete Example

<script server>
import { useForm, v } from 'stx'

const signupForm = useForm({
  username: v.required().min(3).max(20).alphanumeric(),
  email: v.required().email(),
  password: v.required()
    .min(8)
    .hasUppercase('Include uppercase')
    .hasNumber('Include number'),
  confirmPassword: v.required().matches('password', 'Passwords must match'),
  age: v.number().between(13, 120)
})
</script>

<form @submit.prevent="signupForm.handleSubmit(onSubmit)">
  <div class="field">
    <label>Username</label>
    <input name="username" x-value="signupForm.values.username" />
    @error(signupForm.errors.username)
      <span class="error">{{ message }}</span>
    @enderror
  </div>

  <div class="field">
    <label>Password</label>
    <input type="password" name="password" />
    @errors(signupForm.errors.password)
      <li class="error">{{ message }}</li>
    @enderrors
  </div>

  <button type="submit" x-disabled="!signupForm.isValid">
    Sign Up
  </button>
</form>

Head Management

Manage document head (title, meta tags, links, scripts) with a simple, composable API similar to Nuxt's useHead and useSeoMeta.

useHead

Full control over the document head.

import { useHead } from 'stx'

useHead({
  title: 'My Page',
  titleTemplate: '%s | My Site',  // or (title) => `${title} | My Site`
  meta: [
    { name: 'description', content: 'Page description' },
    { property: 'og:image', content: '/og.png' }
  ],
  link: [
    { rel: 'canonical', href: 'https://example.com/page' },
    { rel: 'icon', href: '/favicon.ico' }
  ],
  script: [
    { src: 'https://example.com/analytics.js', async: true }
  ],
  style: [
    { content: 'body { font-family: sans-serif; }' }
  ],
  htmlAttrs: { lang: 'en' },
  bodyAttrs: { class: 'dark' }
})

useSeoMeta

Simplified API for common SEO meta tags - automatically generates Open Graph and Twitter tags.

import { useSeoMeta } from 'stx'

useSeoMeta({
  // Basic
  title: 'My Blog Post',
  description: 'An amazing article about something',
  author: 'Jane Doe',
  keywords: ['blog', 'tech', 'tutorial'],
  robots: 'index, follow',
  canonical: 'https://example.com/blog/post',

  // Open Graph (auto-inherits title/description if not set)
  ogTitle: 'Custom OG Title',
  ogDescription: 'Custom OG description',
  ogImage: 'https://example.com/og.png',
  ogImageAlt: 'What the image shows', // also the card's alt when it reuses ogImage
  ogImageWidth: 1200,
  ogImageHeight: 630,
  ogImageType: 'image/png',
  ogUrl: 'https://example.com/page',
  ogType: 'article',
  ogSiteName: 'My Site',
  ogLocale: 'en_US',

  // Profile (for ogType: 'profile')
  // profileFirstName, profileLastName, profileUsername

  // Twitter (auto-inherits from OG if not set)
  twitterCard: 'summary_large_image',
  twitterSite: '@mysite',
  twitterCreator: '@author',
  // twitterImage, twitterImageAlt

  // Article metadata
  articleAuthor: 'Jane Doe',
  articlePublishedTime: '2024-01-15',
  articleModifiedTime: '2024-01-20',
  articleSection: 'Technology',
  articleTags: ['web', 'development']
})

definePageMeta

Page-level configuration (Nuxt-style).

import { definePageMeta } from 'stx'

definePageMeta({
  title: 'Dashboard',
  description: 'User dashboard page',
  layout: 'admin',
  middleware: ['auth', 'admin'],
  keepAlive: true
})

Template Directives

Quick inline usage without JavaScript:

<!-- Set page title -->
@title('My Page Title')

<!-- Add meta tags -->
@meta('description', 'Page description')
@meta('og:image', '/social.png')

<!-- Raw head content block -->
@head
  <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
  <style>
    body { font-family: 'Inter', sans-serif; }
  </style>
@endhead

Client-Side Updates

For SPA navigation, update the head dynamically:

import { applyHead, useHead } from 'stx'

// Update head configuration
useHead({ title: 'New Page' })

// Apply changes to the DOM
applyHead()

Complete Example

<script server>
import { useSeoMeta, useHead } from 'stx'

const article = {
  title: 'How to Build Fast UIs with STX',
  description: 'Learn the fundamentals of building performant UIs.',
  author: 'Jane Developer',
  publishedAt: '2024-01-15',
  image: 'https://example.com/og-image.png'
}

useSeoMeta({
  title: article.title,
  description: article.description,
  author: article.author,
  ogImage: article.image,
  ogType: 'article',
  twitterCard: 'summary_large_image',
  articlePublishedTime: article.publishedAt,
  canonical: 'https://example.com/blog/fast-uis'
})

useHead({
  link: [{ rel: 'icon', href: '/favicon.ico' }],
  htmlAttrs: { lang: 'en' }
})
</script>

@title('STX Head Demo')
@meta('theme-color', '#4f46e5')

<main>
  <h1>{{ article.title }}</h1>
  <p>{{ article.description }}</p>
</main>

Sitemap & robots.txt

stx ships utilities for generating sitemap.xml and robots.txt from either manual entries or by scanning your pages/ directory. They live in the SEO module and are exported from the package root.

generateSitemap

Build an XML sitemap from a list of entries.

import { generateSitemap } from '@stacksjs/stx'

const sitemap = generateSitemap([
  { loc: '/', priority: 1.0, changefreq: 'weekly' },
  { loc: '/about', priority: 0.8, changefreq: 'monthly' },
  { loc: '/blog/post-1', lastmod: '2026-04-10', priority: 0.6 },
], {
  baseUrl: 'https://example.com',
})

Returns a complete XML string ready to write to disk or return from a request handler.

A page in several languages lists each version under alternates. They are written as <xhtml:link rel="alternate" hreflang="…"> on that <url>, and xmlns:xhtml is added to <urlset> only when an entry has alternates. List every version, this one included: Google ignores a set that isn't reciprocal.

generateSitemap([
  {
    loc: '/pricing',
    alternates: [
      { hreflang: 'en', href: '/pricing' },
      { hreflang: 'de', href: '/pricing?locale=de' },
      { hreflang: 'x-default', href: '/pricing' },
    ],
  },
], { baseUrl: 'https://example.com' })

scanForSitemapEntries

Auto-discover routes by scanning a pages directory. Returns sitemap entries with sensible default priorities (/ = 1.0, top-level pages = 0.8, nested = 0.6).

import { generateSitemap, scanForSitemapEntries } from '@stacksjs/stx'

const entries = await scanForSitemapEntries('./pages', {
  baseUrl: 'https://example.com',
})
const sitemap = generateSitemap(entries, {
  baseUrl: 'https://example.com',
})

generateRobotsTxt

Build a robots.txt from rules.

import { generateRobotsTxt } from '@stacksjs/stx'

const robots = generateRobotsTxt({
  rules: [
    { userAgent: '*', allow: ['/'], disallow: ['/admin', '/api'] },
    { userAgent: 'Googlebot', allow: ['/'] },
  ],
  sitemap: 'https://example.com/sitemap.xml',
})

generateDefaultRobotsTxt

Sensible default — allows everything, points to your sitemap.

import { generateDefaultRobotsTxt } from '@stacksjs/stx'

const robots = generateDefaultRobotsTxt('https://example.com/sitemap.xml')
// User-agent: *
// Allow: /
// Sitemap: https://example.com/sitemap.xml

Wiring as routes

The most common pattern is to expose /sitemap.xml and /robots.txt as API routes via bun-plugin-stx/serve's routes option:

// serve.ts
import { serve } from 'bun-plugin-stx/serve'
import {
  generateDefaultRobotsTxt,
  generateSitemap,
  scanForSitemapEntries,
} from '@stacksjs/stx'

const SITE_URL = 'https://example.com'

await serve({
  patterns: ['pages/'],
  port: 3000,
  routes: {
    '/sitemap.xml': async () => {
      const entries = await scanForSitemapEntries('./pages', { baseUrl: SITE_URL })
      const xml = generateSitemap(entries, { baseUrl: SITE_URL })
      return new Response(xml, {
        headers: { 'Content-Type': 'application/xml' },
      })
    },
    '/robots.txt': async () => {
      const txt = generateDefaultRobotsTxt(`${SITE_URL}/sitemap.xml`)
      return new Response(txt, {
        headers: { 'Content-Type': 'text/plain' },
      })
    },
  },
})

Both routes are now live in dev and prod, generated dynamically from your pages/ directory. New pages appear in the sitemap as soon as they're added — no manual maintenance.

Tip: if you'd rather serve a static sitemap (e.g. you're publishing the same site to a CDN that can't run code), generate it once at build time and write it to public/sitemap.xml. The publicDir handler will serve it like any other static file.


Clean navigation functions that replace raw window.location and window.history usage. All functions are auto-imported.

Navigate to a URL. Uses the SPA router if available, otherwise falls back to full page navigation.

<script client>
// Auto-imported — no import needed

// SPA-style navigation (uses stxRouter when available)
navigate('/about')
navigate('/users/123')

// External URLs work too
navigate('https://example.com')
</script>

<template>
  <button @click="navigate('/dashboard')">Go to Dashboard</button>
  <a @click.prevent="navigate('/settings')">Settings</a>
</template>

goBack() / goForward()

Browser history navigation.

<template>
  <button @click="goBack()">Back</button>
  <button @click="goForward()">Forward</button>
</template>

useRoute()

Reactive route information — replaces window.location reads.

<script client>
const route = useRoute()
</script>

<template>
  <p>Current path: {{ route.path }}</p>
  <p>Full URL: {{ route.fullPath }}</p>
  <p>Hash: {{ route.hash }}</p>
  <p>Query: {{ JSON.stringify(route.query) }}</p>
</template>

useRoute() Return

PropertyTypeDescription
pathstringCurrent pathname (e.g., /about)
fullPathstringFull path with search and hash
hashstringURL hash (e.g., #section)
queryobjectParsed query parameters
paramsobjectRoute params (requires stxRouter)

useSearchParams()

Reactive URL search parameters with get/set methods.

<script client>
const search = useSearchParams()

// Read a param
const page = search.get('page')  // '2'
search.has('page')               // true

// Set a single param (pushes a history entry)
search.set('page', '3')

// Set multiple params at once
search.setAll({ page: '1', sort: 'name', order: 'asc' })

// Remove one
search.delete('page')
</script>

Consuming a one-shot param

Every mutator takes an optional { replace: true } that swaps the pushState for a replaceState. Reach for it whenever the param is a one-shot value — an OAuth callback result, ?checkout=success, a flash token:

<script client>
const search = useSearchParams()

onMount(() => {
  const code = search.get('code')
  if (!code) return
  exchangeForSession(code)
  // Without { replace: true } the URL that still carries `code` stays in
  // history, so pressing Back replays the callback and re-runs the exchange.
  search.delete('code', { replace: true })
})
</script>

{ replace } is spelled the same way navigate() spells it, and the default is unchanged: a mutation pushes unless you say otherwise.


Event Listener Composable

useEventListener(event, handler, options?)

Attach event listeners with automatic cleanup. Replaces raw window.addEventListener / document.addEventListener.

<script client>
// Auto-imported — no import needed

// Listen on window (default target)
useEventListener('resize', () => {
  console.log('Window resized:', window.innerWidth)
})

// Listen on document
useEventListener('keydown', (e) => {
  if (e.key === 'Escape') closeModal()
}, { target: document })

// Listen on specific element by selector
useEventListener('click', handleClick, { target: '#my-button' })

// Manual removal
const { remove } = useEventListener('scroll', handleScroll)
// Later: remove()
</script>

useEventListener Options

OptionTypeDefaultDescription
targetEventTarget | stringwindowEvent target or CSS selector
capturebooleanfalseUse capture phase
passivebooleanfalsePassive listener
oncebooleanfalseAuto-remove after first call

Meta Tag Management

useMeta(options?)

Reactive meta tag management. Replaces document.title, document.querySelector('meta[...]').

<script client>
// Auto-imported — no import needed

// Set initial meta on mount
const meta = useMeta({
  title: 'My Page',
  description: 'A description of my page',
  og: {
    image: 'https://example.com/og.png',
    type: 'website',
  },
})

// Dynamic updates later
meta.setTitle('Updated Title')
meta.setMeta('description', 'New description')
meta.setOgMeta('image', 'https://example.com/new-og.png')
meta.removeMeta('robots')
</script>

useMeta Return

MethodDescription
setMeta(name, content)Set a <meta name="..."> tag
setOgMeta(property, content)Set an Open Graph <meta property="og:..."> tag
removeMeta(name)Remove a meta tag by name
setTitle(title)Set document.title

Timer Composables

useDebounce(fn, delay?)

Wrap a function so it only fires after delay ms of inactivity. Returns a debounced function with .cancel(), .flush(), and .pending() methods.

<script client lang="ts">
const save = useDebounce(() => api.save(form), 300)
save()          // debounced — waits 300ms
save.cancel()   // abort pending call
save.flush()    // fire immediately
save.pending()  // true if a call is queued
</script>

useDebouncedValue(getter, delay?)

Create a debounced read-only value that updates after delay ms of inactivity.

<script client lang="ts">
const debouncedSearch = useDebouncedValue(() => searchInput.value, 300)
debouncedSearch.subscribe(val => console.log('Searching:', val))
console.log(debouncedSearch.value) // current debounced value
</script>

useThrottle(fn, limit?)

Wrap a function so it fires at most once every limit ms. Returns a throttled function with .cancel().

<script client lang="ts">
const onScroll = useThrottle(() => recalcLayout(), 100)
useEventListener('scroll', onScroll)
</script>

useInterval(interval?, options?)

Reactive interval counter with pause/resume/reset controls. Auto-cleans up on destroy.

<script client lang="ts">
const { counter, pause, resume, reset } = useInterval(1000)
// counter increments every second
// pause() / resume() / reset()
</script>
OptionTypeDefaultDescription
immediatebooleanfalseFire callback immediately on start

useTimeout(callback, delay?)

One-shot timeout with start/stop control. Auto-starts and auto-cleans up.

<script client lang="ts">
const { isPending, start, stop } = useTimeout(() => showToast(), 3000)
stop()   // cancel
start()  // restart countdown
</script>

Utility Composables

useToggle(initial?)

Boolean toggle with reactive subscription. Returns a [ref, toggle, set] tuple.

<script client lang="ts">
const [open, toggle, setOpen] = useToggle(false)
toggle()       // true
setOpen(false) // false
open.subscribe(v => console.log('open:', v))
</script>

useCounter(initial?, options?)

Numeric counter with optional min/max bounds.

<script client lang="ts">
const counter = useCounter(0, { min: 0, max: 10 })
counter.inc()   // 1
counter.dec(3)  // 0 (clamped to min)
counter.set(5)  // 5
counter.reset() // 0
</script>
OptionTypeDefaultDescription
minnumber-InfinityMinimum allowed value
maxnumberInfinityMaximum allowed value

useClickOutside(target, handler)

Run a handler when clicking outside a target element. Accepts a CSS selector string or Element.

<script client lang="ts">
const { remove } = useClickOutside('#dropdown', () => closeDropdown())
// Auto-cleans up on destroy, or call remove() manually
</script>

useFocus(target)

Track and control focus state of an element.

<script client lang="ts">
const { isFocused, focus, blur } = useFocus('#search-input')
focus() // programmatically focus
</script>

useAsync(fn, options?)

Wrap an async function with reactive loading/error/data state.

<script client lang="ts">
const { data, isLoading, error, execute } = useAsync(
  () => fetch('/api/users').then(r => r.json()),
  { immediate: true }
)
// Re-execute later:
execute()
</script>
OptionTypeDefaultDescription
immediatebooleanfalseExecute immediately on creation

Color Mode

useColorMode(options?)

Full-featured color-mode manager with system detection, localStorage persistence, cross-tab sync, and <html> class toggling.

<script client lang="ts">
const { mode, isDark, preference, toggle, set } = useColorMode()
toggle()        // switch light ↔ dark
set('auto')     // follow system preference
set('dark')     // force dark
console.log(mode)       // 'light' or 'dark' (resolved)
console.log(preference) // 'light', 'dark', or 'auto' (user choice)
</script>
OptionTypeDefaultDescription
storageKeystring'stx-color-mode'localStorage key
initialMode'light' | 'dark' | 'auto''auto'Initial mode
darkClassstring'dark'CSS class on <html> when dark
attributestring—Use data attribute instead of class (e.g. 'data-theme')
disableTransitionsbooleantruePrevent CSS transition flash during switch

Pre-paint bootstrap

useColorMode is part of the client runtime, so on its own it can only apply the theme after hydration — every cold load paints the default theme first and corrects it, flashing for anyone whose stored preference differs.

Declare app.colorMode in stx.config.ts and stx inlines a render-blocking script at the top of <head> that stamps the root element before first paint:

// stx.config.ts
export default {
  app: {
    colorMode: { storageKey: 'theme', attribute: 'data-theme' },
  },
}

It takes the same options and publishes them to the client, so a bare useColorMode() picks them up rather than repeating them at the call site. Full details in composables-ref.

useDark(options?)

Convenience wrapper around useColorMode — just isDark, toggle, and set.

<script client lang="ts">
const { isDark, toggle } = useDark()
toggle()
</script>

Strict Mode

Strict mode validates client scripts for raw browser API usage and suggests stx composable alternatives.

Configuration

// stx.config.ts
export default {
  // Simple boolean: enables warnings
  strict: true,

  // Or detailed configuration:
  strict: {
    enabled: true,
    failOnViolation: true,     // Throw errors instead of warnings
    allowPatterns: ['cookie'],  // Allow specific patterns through
  },
}

StrictModeConfig

OptionTypeDefaultDescription
enabledbooleanfalseEnable strict mode validation
failOnViolationbooleanfalseThrow errors (true) or warnings (false)
allowPatternsstring[][]Pattern strings to allow through

Prohibited Patterns

When strict mode is enabled, these patterns trigger warnings/errors with suggested alternatives:

PatternSuggested Alternative
document.getElementById()useRef("name")
document.querySelector()useRef("name")
document.querySelectorAll()useRef("name")
document.getElementsBy*()useRef("name")
document.createElement()Template directives or components
document.title = ...useTitle()
document.cookieuseCookie()
document.addEventListener()useEventListener() or @event directives
window.locationnavigate() or useRoute()
window.historynavigate(), goBack(), goForward()
window.addEventListener()useEventListener() or @event directives
window.localStorageuseLocalStorage()
window.sessionStorageuseSessionStorage()
window.scrollTo()useScroll().scrollTo()
window.alert() / confirm() / prompt()stx modal/dialog APIs
location.href = / location.assign() / location.replace()navigate()
setTimeout()useTimeout() or useDebounce()
setInterval()useInterval()
clearTimeout()useTimeout().stop() or useDebounce().cancel()
clearInterval()useInterval().pause()

Example

<!-- This will trigger a warning (or error in strict mode): -->
<script client>
document.getElementById('myBtn')  // ✗ Use useRef("myBtn") instead
window.location.href = '/about'   // ✗ Use navigate('/about') instead
</script>

<!-- This is the stx way: -->
<script client>
const btn = useRef('myBtn')       // ✓ Clean ref access
navigate('/about')                 // ✓ Framework navigation
</script>

Auto-Imported APIs Summary

The following APIs are auto-imported in <script client> blocks — no import statements needed:

Reactivity

state, derived, effect, batch, untrack, peek, isSignal, isDerived

Lifecycle

onMount, onDestroy

Template Refs

useRef

navigate, goBack, goForward, useRoute, useSearchParams

Data Fetching

useFetch, useQuery, useMutation

DOM Utilities

useEventListener, useMeta

Timers

useDebounce, useDebouncedValue, useThrottle, useInterval, useTimeout

Utilities

useToggle, useCounter, useClickOutside, useFocus, useAsync

Color Mode

useColorMode, useDark

Vue-Style Reactivity

ref, reactive, computed, watch, watchEffect, watchMultiple

Vue-Style Lifecycle

onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted

Component Definition

defineProps, withDefaults, defineEmits, defineExpose

Composition API

provide, inject, nextTick, getCurrentInstance, onErrorCaptured, useSlots, useAttrs

State Management

createStore, defineStore, action, createSelector

JSX

h, Fragment


v2 Features

STX v2 introduces TypeScript-first single-file components with automatic hydration. These features build incrementally on v1 — existing .stx files continue to work unchanged.

Signals vs Alpine-Style Reactivity

Alpine-style (x-data) deprecatedSignals (state/derived/effect)
Syntaxx-data — use <script client> with signalsstate(), derived(), effect(), {{ }}, :attr, @event
TypeScriptNo (inline JS expressions)Yes (full TS in <script> blocks)
ScopePer-element via x-data="{ ... }"Per-component via <script client> block
Use caseQuick inline interactivityAll client-side reactivity
Lifecyclex-init onlyonMount, onDestroy
Derived stateManual (recompute in handlers)derived(() => ...) auto-tracks
Two-way bindingx-model="name"x-model="name" (same)
Filereactive.ts runtimesignals.ts runtime

Recommendation: Use <script client> with signals for all client-side reactivity. x-data is deprecated.

<!-- Signals-based reactivity -->
<script client>
const open = state(false)
const count = state(0)
</script>

<div>
  <button @click="open.set(!open())">Toggle</button>
  <div :show="open">Signals-managed</div>
</div>

<div>
  <p>{{ count() }}</p>
  <button @click="count.set(count() + 1)">Increment</button>
</div>

TypeScript-First Scripts

All <script> blocks are TypeScript by default. Type annotations, interfaces, and generics are stripped at build time via Bun.Transpiler({ loader: 'ts' }).

<script>
interface Job {
  id: string
  name: string
  status: 'active' | 'completed' | 'failed'
}

const jobs = state<Job[]>([])
const filter = state<string>('all')

async function loadJobs(): Promise<void> {
  const res: Response = await fetch('/api/jobs')
  jobs.set(await res.json())
}
</script>

No lang="ts" attribute needed. To opt out, use <script js> or <script lang="js">.

Works in all script types: <script>, <script server>, <script client>, and <script type="module">.

Auto-Binding

Scripts no longer need manual stx.mount() calls. The compiler analyzes the template for referenced identifiers and automatically wraps scripts in stx.mount() when their declarations match template bindings.

Before (v1)

<script>
stx.mount(function() {
  const greeting = 'Hello'
  function handleClick() { console.log('clicked') }
  return { greeting, handleClick }
})
</script>
<div>{{ greeting }}</div>
<button @click="handleClick()">Click</button>

After (v2)

<script>
const greeting = 'Hello'
function handleClick() { console.log('clicked') }
</script>
<div>{{ greeting }}</div>
<button @click="handleClick()">Click</button>

The compiler detects that greeting and handleClick are referenced in {{ }} and @click expressions, and auto-generates the mount wrapper with the correct return statement.

How it works:

  1. Template is scanned for identifiers in {{ }}, :attr="", @event="", @model="", @for="" expressions
  2. Script is scanned for top-level declarations (const, let, var, function)
  3. If any declarations match template references, the script is wrapped in stx.mount() with an auto-generated return statement
  4. Scripts with explicit stx.mount() calls are never double-wrapped
  5. Scripts with no template references remain as plain IIFEs

Scoped Styles

<style scoped> isolates CSS to the component using attribute-based selector scoping.

<!-- components/card.stx -->
<template>
  <div class="card">
    <h2>{{ title }}</h2>
    <p>{{ description }}</p>
  </div>
</template>

<style scoped>
.card {
  padding: 1rem;
  border: 1px solid #eee;
  border-radius: 0.5rem;
}

.card h2 {
  font-size: 1.25rem;
  margin-bottom: 0.5rem;
}
</style>

Output:

  • CSS selectors become [data-v-stx-abc123] .card { ... }
  • All HTML elements get data-v-stx-abc123 attribute
  • Hash is deterministic (based on file path) — same file always produces the same hash
  • Non-scoped <style> tags pass through unchanged
  • Supports nested selectors, @media queries, @keyframes, and comma-separated selectors

Mixing scoped and global styles:

<style>
/* Global — applies everywhere */
.global-reset { margin: 0; }
</style>

<style scoped>
/* Scoped — only this component */
.card { background: white; }
</style>

App Shell

Single-shell architecture: one app.stx wraps all pages. The shell's <slot /> is the page injection point.

Shell file

<!-- app.stx -->
<template>
  <div class="app">
    <nav>
      <a href="/" data-stx-link>Dashboard</a>
      <a href="/jobs" data-stx-link>Jobs</a>
      <a href="/settings" data-stx-link>Settings</a>
    </nav>

    <main>
      <slot />
    </main>
  </div>
</template>

<style>
.app { display: flex; min-height: 100vh; }
nav { width: 200px; padding: 1rem; }
main { flex: 1; padding: 2rem; }
</style>

Page files

<!-- pages/index.stx -->
<h1>Dashboard</h1>
<p>Welcome back!</p>

<!-- pages/jobs.stx -->
<script server>
const jobs = await fetchJobs()
</script>
<h1>Jobs</h1>
@foreach(jobs as job)
  <div>{{ job.name }}</div>
@endforeach

How it works:

  • app.stx is auto-detected in the project root (or configured via shell in stx.config.ts)
  • Direct browser requests get the full shell + page pre-rendered into <slot />
  • SPA navigation (data-stx-link clicks) fetch only the page fragment
  • The router swaps fragments into <div data-stx-content> without full page reloads
  • Shell scripts, styles, and layout persist across navigations

Configuration:

// stx.config.ts
export default {
  shell: 'app.stx',        // custom shell path
  // shell: false,          // disable shell auto-detection
}

No shell? Everything works as before — full HTML documents per page.

Client-Side Router Hydration

The SPA router handles two response types:

RequestHeaderServer ResponseRouter Behavior
Direct (browser)NoneFull shell + pageNormal page load
SPA navigationX-STX-Router: truePage fragmentInject into [data-stx-content]

Fragment handling:

  1. Router detects fragment (no <!DOCTYPE/<html> wrapper)
  2. Extracts <script> and <style> tags from fragment
  3. Removes old page styles ([data-stx-page]), injects new ones into <head>
  4. Swaps HTML content into the content container
  5. Re-executes page scripts
  6. Dispatches stx:navigate and stx:load events
  7. Signals runtime re-processes [data-stx-scope] and [data-stx] elements

View Transitions API is used when available, with a CSS opacity fallback for older browsers.

<StxLink> Component

Built-in navigation component (equivalent to Nuxt's <NuxtLink>). Renders an <a> tag with SPA navigation, active class management, and optional prefetching.

<!-- Static path -->
<StxLink to="/about">About</StxLink>

<!-- Dynamic path (works inside @for loops) -->
<StxLink x-to="'/jobs/' + job.id">View Details</StxLink>

<!-- Custom active class -->
<StxLink to="/jobs" activeClass="nav-active">Jobs</StxLink>

<!-- Exact active class (only on exact path match) -->
<StxLink to="/" exactActiveClass="current-page">Home</StxLink>

<!-- Prefetch on hover -->
<StxLink to="/dashboard" prefetch>Dashboard</StxLink>

<!-- With CSS class -->
<StxLink to="/settings" className="btn btn-link">Settings</StxLink>

Props:

PropDefaultDescription
to—Target URL (static string)
:to—Target URL (dynamic expression, evaluated client-side)
activeClass'active'Class added when route matches or is a parent
exactActiveClass'exact-active'Class added only on exact path match
prefetchfalsePrefetch page content on hover
className''CSS class for the element

Link behavior:

ElementBehavior
<StxLink to="/path">SPA navigation (no page reload)
<a href="/path">Full page reload (native browser behavior)

<StxLink> does SPA navigation — the router fetches the page fragment and swaps the content without reloading. Plain <a href> always does a native full page reload.

<StxImage> Component

Enhanced image with lazy loading, responsive support, and placeholder.

<!-- Basic -->
<StxImage src="/photo.jpg" alt="Mountain trail" />

<!-- With dimensions (prevents layout shift) -->
<StxImage src="/photo.jpg" alt="Photo" width="800" height="600" />

<!-- Eager loading (above the fold) -->
<StxImage src="/hero.jpg" alt="Hero" lazy="{{ false }}" />

<!-- With responsive srcset -->
<StxImage src="/photo.jpg" alt="Photo" sizes="(max-width: 768px) 100vw, 50vw" srcset="/photo-400.jpg 400w, /photo-800.jpg 800w" />

<!-- With blur placeholder -->
<StxImage src="/photo.jpg" alt="Photo" placeholder="data:image/jpeg;base64,..." />

Props:

PropDefaultDescription
src—Image URL (required)
alt''Alt text
width—Image width
height—Image height
lazytrueLazy loading (loading="lazy")
sizes—Responsive sizes attribute
srcset—Responsive srcset attribute
placeholder—Placeholder image URL (shown while loading)
className''CSS class

All images use decoding="async" for non-blocking decode.

Component Composition API

Full typed component composition available in both server and client scripts.

defineProps<T>()

Declare typed props with optional validation and defaults.

<script>
interface StatCardProps {
  label: string
  value: string | number
  color?: string
}

const props = defineProps<StatCardProps>()
</script>

<template>
  <div class="stat">
    <span class="label">{{ props.label }}</span>
    <span class="value">{{ props.value }}</span>
  </div>
</template>

At runtime, defineProps() reads props from the component context:

  • Server-side: from __STX_CURRENT_PROPS__ (set by component renderer)
  • Client-side: from data-stx-props attribute on the component element (JSON-serialized during SSR)

withDefaults()

Merge default values into props.

<script>
const { label, value, color } = withDefaults(defineProps<{
  label: string
  value: string | number
  color?: string
}>(), {
  color: 'text-zinc-50',
})
</script>

defineEmits<T>()

Declare and emit typed events from components.

<script>
const emit = defineEmits<{
  select: { id: string }
  delete: string
}>()

function handleSelect(id: string) {
  emit('select', { id })
}
</script>

Events are dispatched as CustomEvent with detail payload. Parents listen via standard event handling or @event syntax.

defineExpose()

Expose component methods/state to parent components.

<script>
const count = state(0)

defineExpose({
  increment: () => count.set(count() + 1),
  getCount: () => count(),
})
</script>

Exposed methods are stored on the element as el.__stx_exposed.

Auto-Imports

All composition functions are auto-imported in client scripts — no import statement needed:

defineProps, withDefaults, defineEmits, defineExpose,
provide, inject, nextTick, getCurrentInstance,
onErrorCaptured, useSlots, useAttrs

These are available from window.stx at runtime.


Project Structure

stx uses a convention-based project structure. No HTML boilerplate in .stx files — the framework generates the document shell.

stx.config.ts              ← config (head, root, app settings)
pages/                     ← file-based routes
  index.stx                ← /
  about.stx                ← /about
  jobs/[id].stx            ← /jobs/:id (dynamic)
layouts/
  default.stx              ← root layout (auto-applied, no @extends needed)
components/                ← reusable components
partials/                  ← includes (@include)
.stx/                      ← cache (auto-generated)
.output/                   ← production build (auto-generated)

For Stacks apps (embedded mode), set root: 'resources/views' in config. All stx directories resolve from that root. .stx/ and .output/ stay at project root.

Configuration (stx.config.ts)

export default {
  // Source root (default: '.', auto-detects 'resources/views')
  root: '.',

  // Document shell — auto-generated around every page
  app: {
    head: {
      title: 'My App',
      lang: 'en',
      meta: [
        { name: 'description', content: 'My stx app' },
      ],
      link: [
        { rel: 'icon', href: '/favicon.ico' },
      ],
      bodyClass: 'dark min-h-screen',
    },
  },

  // Directory conventions
  // partialsDir: 'partials',
  // componentsDir: 'components',
  // layoutsDir: 'layouts',
  // defaultLayout: 'default',
}

Production Build

# Build for production
stx build --out .output

# Start production server
stx start --port 3000

The production build:

  • Compiles all templates at build time
  • Externalizes shared runtimes into fingerprinted JS files
  • Pre-extracts SPA fragments for instant navigation
  • Generates a manifest for the production server

The production server:

  • Serves static pages directly (zero processing, <1ms)
  • Hydrates dynamic pages with request-time data
  • Serves SPA fragments for client-side navigation
  • Sets immutable cache headers on fingerprinted assets

For more details, see the full documentation.