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)
- Script Types
- Template Expressions
- Components
- Props & Slots
- Layouts & Sections
- Directives
- Prefix Convention
- Event Handling
- Icons
- Custom Directives
- Configuration
- State Management
- Lifecycle Hooks
- Refs (DOM References)
- Transitions
- Deferred Loading (@defer)
- Teleport (@teleport)
- Browser Composables
- Navigation API
- Event Listener Composable
- Meta Tag Management
- Timer Composables
- Utility Composables
- Color Mode
- Strict Mode
- Auto-Imported APIs Summary
- Form Validation
- Head Management
- Static Site Generation (SSG)
- Visual Testing
- Hydration Runtime
- Component HMR
- Deployment
- Image Optimization
- Suggested Future Syntax
- v2 Features
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 -->
<scriptserver
// Server-side only - extracted for SSR, stripped from output
const title: string = props.title || 'Default Title'
const count: number = props.count || 0
</script>
<template
<divclass="">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<slot />
</div>
</template>
<stylescoped
.card {
padding: 1rem;
border: 1px solid #ccc;
}
</style>
<scriptclient
// 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
| Type | Behavior |
|---|---|
<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
<scriptserver
// Runs during SSR, variables available in template
const user = await fetchUser()
const posts = await getPosts(user.id)
</script>
Client Script
<scriptclient
// 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 -->
<UserCardname=""role="" />
<!-- With slot content -->
<Cardtitle="">
<p>This content goes into the slot</p>
</Card>
<!-- Nested in folders: components/forms/Input.stx -->
<FormsInputtype="" />
Explicit Imports
@import('layouts/Sidebar')
@import('shared/Button', 'shared/Modal')
<Sidebar />
<Buttonlabel="" />
Props & Slots
Passing Props
<!-- String prop -->
<Cardtitle="" />
<!-- Expression binding with : -->
<Cardcount=""active="" />
<!-- Mustache interpolation -->
<Cardtitle="" />
<!-- Boolean props -->
<Buttondisabled />
<Modalvisible="" />
Accessing Props in Components
Simple props access
<scriptserver
// 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<stringunknown> }
}>()
function handleClick() {
emit('change', 'new value')
}
</script>
Parent listens via CustomEvent:
<my-componentchange="" />
Slots
<!-- Default slot -->
<template
<divclass="">
<slot />
</div>
</template>
<!-- With default content -->
<template
<divclass="">
<slot>Default content if no slot provided</slot>
</div>
</template>
Layouts & Sections
Defining a Layout
<!-- layouts/default.stx -->
<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')
<linkrel=""href="">
@endsection
@section('content')
<h1>Page Content</h1>
@endsection
@section('scripts')
<scriptsrc=""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)
<imgsrc="" />
@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
<ahref="">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')
<scriptsrc=""script
@endpush
@push('styles')
<linkrel=""href="">
@endpush
<!-- Prepend to stack -->
@prepend('scripts')
<scriptsrc=""script
@endprepend
@once
<!-- Only renders once even if component is used multiple times -->
@once
<scriptsrc=""script
@endonce
Forms
<formmethod="">
@csrf
<!--<inputtype=""name=""value=""> -->
</form>
@method
<formmethod="">
@method('PUT')
<!--<inputtype=""name=""value=""> -->
</form>
@error
<inputname=""type="">
@error('email')
<spanclass="">{{ 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:
| Prefix | Purpose | Examples |
|---|---|---|
: | Structural directives — control flow | :if, :show, :for, :key |
x- | Attribute bindings & content — dynamic values | x-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)
<divif="">Welcome back</div>
<divshow="">Menu content</div>
<divfor=""key="">{{ item.name }}</div>
Attribute Bindings (x- prefix)
<!-- Dynamic attributes -->
<ax-href="">Profile</a>
<imgx-src=""x-alt="" />
<!-- Dynamic classes and styles -->
<divx-class="">Status</div>
<divx-style="">Themed</div>
<!-- Content -->
<spanx-text=""></span>
<divx-html=""></div>
<!-- Form binding -->
<inputx-model=""type=""placeholder="">
Events (@ prefix)
<buttonclick="">Click me</button>
<buttonclick="">Increment</button>
<formsubmitprevent=""></form>
<inputkeydownenter="">
Reactivity
All client-side state lives in <script client> blocks using signals. — state belongs in TypeScript, not inline HTML attributes.x-data is deprecated
<scriptclient
const count = state(0)
const email = state('')
const doubled = derived(() => count() * 2)
</script>
<spanx-text=""></span>
<buttonclick="">+1</button>
<p>Doubled: <spanx-text=""></span></p>
<inputx-model=""type="">
<p>Email: <spanx-text=""></span></p>
x-model
Two-way binding for form inputs:
<scriptclient
const email = state('')
const password = state('')
</script>
<inputx-model=""type=""placeholder="">
<inputx-model=""type=""placeholder="">
<p>Email: <spanx-text=""></span></p>
x-text
Reactive text content:
<scriptclient
const count = state(0)
</script>
<spanx-text=""></span>
<buttonclick="">Increment</button>
Event Handling
@click
<buttonclick="">Click me</button>
<buttonclick="">Increment</button>
<buttonclick="">Toggle</button>
Other Events
<inputinput="">
<inputchange="">
<formsubmit="">
<divmouseover=""mouseout="">
Event Modifiers
<!-- Prevent default -->
<formsubmitprevent="">
<!-- Stop propagation -->
<buttonclickstop="">
<!-- Once only -->
<buttonclickonce="">
<!-- Key modifiers -->
<inputkeyupenter="">
<inputkeyupescape="">
Icons
STX integrates with Iconify for 200K+ icons.
Usage
<HomeIconsize="" />
<SearchIconsize=""color="" />
<UserIconsize=""class="" />
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
<!--<divclass=""><p></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:
<scriptclient
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>
<buttonclick=""></button>
<buttonclick=""></button>
</div>
</template>
Store API Reference
| Property/Method | Description |
|---|---|
store.property | Access state property directly |
store.getter | Access computed getter |
store.action() | Call an action |
store.$state | Get full state snapshot |
store.$subscribe(callback) | Subscribe to state changes |
store.$patch(partial) | Update multiple state properties |
store.$reset() | Reset to initial state |
store.$id | Get 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:
<html>
<head>
<!-- Load store runtime -->
<scriptsrc=""script
</head>
<body>
<!-- Your app content -->
<scriptclient
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:
<scriptclient
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.
<scriptclient
// 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.
<scriptclient
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).
<scriptclient
import { onUpdate } from 'stx'
onUpdate(() => {
console.log('Component updated!')
})
</script>
Aliases
| Hook | Alias |
|---|---|
onMount | onMounted |
onDestroy | onUnmounted |
onUpdate | onUpdated |
Full Example
<scriptclient
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.
useRef() (Recommended)
The useRef() composable is the preferred way to reference DOM elements. It is auto-imported — no import statement needed.
<template
<inputtype=""ref=""placeholder="" />
<buttonclick="">Select All</button>
</template>
<scriptclient
// 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 -->
<inputref=""type="" />
<inputref=""type="" />
<divref=""></div>
</template>
<scriptclient
const email = useRef('emailRef')
const password = useRef('passwordRef')
const container = useRef('containerRef')
onMount(() => {
email.current?.focus()
})
</script>
Multiple Refs
<template
<formref="">
<inputref=""type="" />
<inputref=""type="" />
<buttontype="">Login</button>
</form>
</template>
<scriptclient
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
| Property | Description |
|---|---|
ref.value | The DOM element (or null if not bound) |
ref.current | Alias for ref.value |
Legacy ref() (Still Supported)
The Vue-style ref() function also works for creating refs:
<scriptclient
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)
<divclass="">Modal Content</div>
@endif
@endtransition
Transition Options
| Option | Description | Default |
|---|---|---|
name | CSS class prefix | 'stx' |
duration | Duration in ms | 300 |
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:
<divtransitionfade="">Fading content</div>
<divtransitionslide-up.500="">Sliding content</div>
CSS Classes
STX applies these CSS classes during transitions:
Enter Transition:
| Class | When Applied |
|---|---|
{name}-enter-from | Initial state before entering |
{name}-enter-active | Active state during enter |
{name}-enter-to | Final state after entering |
Leave Transition:
| Class | When Applied |
|---|---|
{name}-leave-from | Initial state before leaving |
{name}-leave-active | Active state during leave |
{name}-leave-to | Final 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)
<navclass=""></nav>
@endif
@endtransition
JavaScript Transition Hooks
For programmatic control:
<scriptclient
// 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>
Modal Example
<template
<buttonclick="">Open Modal</button>
@transition(name: 'fade')
@if (showModal)
<divclass=""clickself="">
@transition(name: 'scale')
<divclass="">
<h2>Modal Title</h2>
<p>Modal content goes here</p>
<buttonclick="">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) -->
<divclass="">
<header>
<slotname="">Default Header</slot>
</header>
<main>
<slot /> <!-- Default slot -->
</main>
<footer>
<slotname="" />
</footer>
</div>
<!-- Parent Usage -->
<Card>
<templateheader
<h1>Custom Title</h1>
</template>
<p>This goes in the default slot</p>
<templatefooter
<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>
<slotname=""item=""index="">
<!-- Default content if no slot provided -->
<td>{{ item }}</td>
</slot>
</tr>
@endforeach
</tbody>
</table>
<!-- Parent Usage -->
<DataTableitems="">
<templaterow=""
<td>{{ index + 1 }}</td>
<td>{{ item.name }}</td>
<td>{{ item.email }}</td>
<td>
<buttonclick="">Edit</button>
</td>
</template>
</DataTable>
Slot Syntax Variants
<!-- Hash syntax (recommended) -->
<templateheadertemplate
<templaterow=""template
<!-- v-slot syntax (Vue compatible) -->
<templatev-slot:headertemplate
<templatev-slot:row=""template
<!-- slot attribute (legacy) -->
<templateslot=""template
Default Slot Content
Provide fallback content when no slot is provided:
<!-- Button.stx -->
<buttonclass="">
<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>
<divclass="">
<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>
<divclass="">
<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>
<buttonclick="">
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
<divclass="">
<p>Something went wrong</p>
<buttonclick="">Retry</button>
</div>
@enderrorBoundary
With Options
@errorBoundary(id: 'user-section', logErrors: false)
<UserProfileid="" />
<UserActivityid="" />
@fallback
<divclass="">
<h3>Failed to load user data</h3>
<buttonclick="">Try Again</button>
</div>
@enderrorBoundary
Options Reference
| Option | Type | Default | Description |
|---|---|---|---|
id | string | auto-generated | Custom ID for error tracking |
logErrors | boolean | true | Whether to log errors to console |
Retry Functionality
The $retry() function resets the error boundary and re-renders the content:
@errorBoundary
<DataGriddata="" />
@fallback
<divclass="">
<spanclass=""></span>
<h4>Data failed to load</h4>
<p>Check your connection and try again</p>
<buttonclick="">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:
<scriptclient
// 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:
<scriptclient
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')
<divclass="">
<divclass=""></div>
<p>Loading chart</p>
</div>
@error
<divclass="">
<p>Failed to load chart</p>
<buttononclick="">Retry</button>
</div>
@endasync
Options
| Option | Type | Default | Description |
|---|---|---|---|
component | string | required | Component path or name to load |
timeout | number | 30000 | Timeout in milliseconds |
delay | number | 200 | Delay before showing loading state |
retries | number | 0 | Number of retry attempts on failure |
suspensible | boolean | true | Whether to notify parent Suspense boundary |
With All Options
@async(component: 'Dashboard', timeout: 10000, delay: 100, retries: 3)
<DashboardSkeleton />
@error
<divclass="">
<h4>Dashboard failed to load</h4>
<pdata-error-message></p>
<buttononclick="">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
<scriptclient
// 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
<scriptclient
// 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
<UserProfileid="" />
<UserActivityid="" />
<UserStatsid="" />
@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
<scriptclient
// 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
<scriptclient
// 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')
<componentis="" />
@endkeepAlive
Options
| Option | Type | Default | Description |
|---|---|---|---|
max | number | 10 | Maximum cached instances (LRU eviction) |
include | string | - | Comma-separated component names to cache |
exclude | string | - | Comma-separated component names to NOT cache |
id | string | auto | Custom cache ID |
Tab Navigation Example
<script
let activeTab = 'home'
</script>
<nav>
<buttonclick=""class="">
Home
</button>
<buttonclick=""class="">
Search
</button>
<buttonclick=""class="">
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 -->
<divclass=""data-keep-scroll="">
@foreach (items as item)
<ListItemitem="" />
@endforeach
</div>
Preserving Form State
<!-- Form values are automatically preserved -->
<form>
<inputname=""type="" />
<selectname="">
<optionvalue="">All</option>
<optionvalue="">Active</option>
</select>
<inputtype=""name="" />
</form>
<!-- For custom state, use data-keep-state -->
<divdata-keep-state=""data-state-value="">
Count: {{ count }}
</div>
Lifecycle Hooks
<scriptclient
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
<scriptclient
// 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
<scriptclient
// 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
| Option | Type | Description |
|---|---|---|
type | Constructor | Expected type (String, Number, Boolean, Array, Object, etc.) |
required | boolean | Whether the prop is required |
default | T | () => T | Default value (use function for objects/arrays) |
validator | (value) => boolean | Custom validation function |
description | string | Documentation 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>
<buttonclick="">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>
<inputref=""x-value="" />
Parent can access exposed methods:
<script
const childRef = ref()
function focusChild() {
childRef.value?.focus()
}
</script>
<MyInputref="" />
<buttonclick="">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:
| Key | Action |
|---|---|
| Click header | Toggle panel open/closed |
| Click component | Select and highlight component |
Disabling DevTools
import { disableDevTools } from 'stx'
// Disable DevTools (e.g., in production)
disableDevTools()
Best Practices
- Only enable in development - DevTools adds overhead
- Use component names - Makes debugging easier
- Register stores - For full state visibility
- Record custom events - Track user interactions
- 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:
| Directive | Description | Example |
|---|---|---|
@uppercase | Convert to uppercase | @uppercase text @enduppercase |
@lowercase | Convert to lowercase | @lowercase TEXT @endlowercase |
@capitalize | Capitalize first letter | @capitalize hello @endcapitalize |
@trim | Remove 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 |
@code | Inline code style | @code const x = 1 @endcode |
@tooltip(text) | Add tooltip | @tooltip(text: 'Help') Hover me @endtooltip |
@clipboard | Copy 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
| Hook | When Called | Use Case |
|---|---|---|
onRegister | Plugin is registered | Initialize state |
beforeProcess | Before template processing | Add global variables |
afterParse | After parsing, before directives | Template analysis |
beforeDirective | Before each directive phase | Modify behavior |
afterDirective | After each directive phase | Post-process |
beforeRender | Before final output | Last modifications |
afterRender | After final output | Add wrappers/scripts |
onError | On processing error | Error recovery |
onUnregister | Plugin is removed | Cleanup |
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
| Trigger | Description |
|---|---|
visible | Loads when element enters viewport (IntersectionObserver) |
idle | Loads when browser is idle (requestIdleCallback) |
interaction | Loads on first click, focus, or touch |
hover | Loads on mouse hover or focus |
timer(ms) | Loads after specified milliseconds |
immediate | Loads immediately but async (default) |
Examples
<!-- Load on scroll into view -->
@defer(on: 'visible')
<ImageGalleryimages="" />
@placeholder
<divclass=""></div>
@enddefer
<!-- Load on hover (prefetch-like) -->
@defer(on: 'hover')
<PreviewCarddata="" />
@placeholder
<span>Hover to preview</span>
@enddefer
<!-- Load after 2 seconds -->
@defer(on: 'timer(2000)')
<AnalyticsWidget />
@loading
<divclass=""></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')
<divclass="">
<h2>Modal Title</h2>
<p>Modal content here</p>
</div>
@endteleport
<!-- Target element somewhere in the document -->
<divid=""></div>
With Disabled State
@teleport('#modals', disabled: isInline)
<divclass="">
Renders inline when isInline is true
</div>
@endteleport
Common Patterns
<!-- Modal with teleport -->
<buttonclick="">Open Modal</button>
@teleport('#modal-container')
<divclass=""x-show="">
<divclass="">
<h2>{{ modalTitle }}</h2>
<slot />
<buttonclick="">Close</button>
</div>
</div>
@endteleport
<!-- Tooltip teleported to body -->
@teleport('body')
<divclass=""x-style="">
{{ tooltipText }}
</div>
@endteleport
<!-- Notification toast -->
@teleport('#notifications')
<divclass="">
{{ 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
| Option | Type | Default | Description |
|---|---|---|---|
pagesDir | string | 'pages' | Directory containing page templates |
outputDir | string | 'dist' | Output directory for generated files |
publicDir | string | 'public' | Static assets directory |
domain | string | - | Domain for sitemap URLs |
sitemap | boolean | true | Generate sitemap.xml |
rss | boolean | false | Generate RSS feed |
generate404 | boolean | true | Generate 404.html |
cache | boolean | true | Enable build caching |
parallel | number | 4 | Parallel page generation |
cleanOutput | boolean | false | Clean 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
| Strategy | Description |
|---|---|
eager | Hydrate immediately on page load |
lazy | Hydrate on first user interaction |
idle | Hydrate when browser is idle |
visible | Hydrate 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
| Option | Type | Default | Description |
|---|---|---|---|
watchDir | string | 'components' | Directory to watch |
extensions | string[] | ['.stx'] | File extensions to watch |
debounce | number | 100 | Debounce delay in ms |
preserveState | boolean | true | Preserve component state |
overlay | boolean | true | Show 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
| Variable | Purpose |
|---|---|
NETLIFY_AUTH_TOKEN | API authentication token |
NETLIFY_SITE_ID | Default 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
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | required | Source image path |
alt | string | required | Alt text (accessibility) |
width | number | - | Display width |
height | number | - | Display height |
sizes | string | 100vw | Responsive sizes attribute |
loading | 'lazy' | 'eager' | 'lazy' | Loading strategy |
priority | boolean | false | Preload (above-fold images) |
quality | number | 80 | Output quality (1-100) |
placeholder | 'blur' | 'color' | 'none' | 'none' | Placeholder type |
formats | string[] | ['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)
<UserCarduser="" />
@endforeach
@endfetch
<!-- With loading/error states -->
@fetch(url="/api/posts" as="posts")
@loading
<Spinner />
@endloading
@error
<ErrorMessageerror="" />
@enderror
@success
<PostListposts="" />
@endsuccess
@endfetch
Transitions & Animations
<!-- Declarative transitions -->
@transition('fade')
<Modalvisible="" />
@endtransition
<!-- Named transitions -->
@transition('slide-up', { duration: 300 })
<Notificationshow="" />
@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')
<Modalshow="">
<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 }}")
<ExpensiveUserProfileuser="" />
@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
<scriptserver
// 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:
| File | Route |
|---|---|
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:
<scriptserver
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 })
<divclass="">
<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 })
<divclass="">
<imgsrc=""alt="" />
<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 })
<articleclass="">
<h2>{{ item.title }}</h2>
<p>{{ item.excerpt }}</p>
</article>
@loading
<divclass="">Loading more posts</div>
@endinfiniteList
Virtual List Options
| Option | Type | Default | Description |
|---|---|---|---|
itemHeight | number | required | Height of each item in pixels |
containerHeight | number | required | Height of the scroll container |
overscan | number | 3 | Buffer items outside viewport |
keyProperty | string | 'id' | Property to use as key |
dynamicHeight | boolean | false | Enable variable item heights |
containerClass | string | '' | CSS class for container |
itemClass | string | '' | 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)
<ImageCarouselimages="" />
@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
<Tooltipcontent="" />
@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
| Option | Type | Default | Description |
|---|---|---|---|
staleTime | number | 0 | Time in ms before data is considered stale |
cacheTime | number | 300000 | Time in ms to keep data in cache |
cacheKey | string | URL | Custom cache key |
refetchOnFocus | boolean | false | Refetch when tab regains focus |
refetchInterval | number | - | Polling interval in ms |
immediate | boolean | true | Fetch on mount |
transform | function | - | Transform response data |
headers | object | - | Request headers |
onSuccess | function | - | Success callback |
onError | function | - | Error callback |
useQuery Return
| Property | Type | Description |
|---|---|---|
data | Signal | Reactive data signal |
loading | Signal | Loading state |
error | Signal | Error message (null if none) |
isStale | Signal | Whether data is stale |
refetch() | function | Manually re-fetch |
invalidate() | function | Clear 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
| Option | Type | Default | Description |
|---|---|---|---|
method | string | 'POST' | HTTP method |
headers | object | - | Request headers |
transform | function | - | Transform response |
optimisticData | T | function | - | Optimistic update data |
invalidateQueries | string[] | - | Cache keys to invalidate on success |
onSuccess | function | - | Success callback |
onError | function | - | Error callback |
useMutation Return
| Property | Type | Description |
|---|---|---|
data | Signal | Response data |
loading | Signal | Loading state |
error | Signal | Error message |
mutate(body) | function | Execute the mutation |
reset() | function | Reset 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)
<spanclass="">{{ message }}</span>
@enderror
<!-- Multiple errors as list -->
@errors(form.errors.password)
<li>{{ message }}</li>
@enderrors
<!-- Conditional block if has errors -->
@hasErrors(form.errors.email)
<divclass="">
...
</div>
@endhasErrors
Complete Example
<scriptserver
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>
<formsubmitprevent="">
<divclass="">
<label>Username</label>
<inputname=""x-value="" />
@error(signupForm.errors.username)
<spanclass="">{{ message }}</span>
@enderror
</div>
<divclass="">
<label>Password</label>
<inputtype=""name="" />
@errors(signupForm.errors.password)
<liclass="">{{ message }}</li>
@enderrors
</div>
<buttontype=""x-disabled="">
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
<linkrel=""href=""as=""type=""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
<scriptserver
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.
Navigation API
Clean navigation functions that replace raw window.location and window.history usage. All functions are auto-imported.
navigate(url)
Navigate to a URL. Uses the SPA router if available, otherwise falls back to full page navigation.
<scriptclient
// 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
<buttonclick="">Go to Dashboard</button>
<aclickprevent="">Settings</a>
</template>
goBack() / goForward()
Browser history navigation.
<template
<buttonclick="">Back</button>
<buttonclick="">Forward</button>
</template>
useRoute()
Reactive route information — replaces window.location reads.
<scriptclient
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
| Property | Type | Description |
|---|---|---|
path | string | Current pathname (e.g., /about) |
fullPath | string | Full path with search and hash |
hash | string | URL hash (e.g., #section) |
query | object | Parsed query parameters |
params | object | Route params (requires stxRouter) |
useSearchParams()
Reactive URL search parameters with get/set methods.
<scriptclient
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:
<scriptclient
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.
<scriptclient
// 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
| Option | Type | Default | Description |
|---|---|---|---|
target | EventTarget | string | window | Event target or CSS selector |
capture | boolean | false | Use capture phase |
passive | boolean | false | Passive listener |
once | boolean | false | Auto-remove after first call |
Meta Tag Management
useMeta(options?)
Reactive meta tag management. Replaces document.title, document.querySelector('meta[...]').
<scriptclient
// 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
| Method | Description |
|---|---|
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.
<scriptclientlang=""
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.
<scriptclientlang=""
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().
<scriptclientlang=""
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.
<scriptclientlang=""
const { counter, pause, resume, reset } = useInterval(1000)
// counter increments every second
// pause() / resume() / reset()
</script>
| Option | Type | Default | Description |
|---|---|---|---|
immediate | boolean | false | Fire callback immediately on start |
useTimeout(callback, delay?)
One-shot timeout with start/stop control. Auto-starts and auto-cleans up.
<scriptclientlang=""
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.
<scriptclientlang=""
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.
<scriptclientlang=""
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>
| Option | Type | Default | Description |
|---|---|---|---|
min | number | -Infinity | Minimum allowed value |
max | number | Infinity | Maximum allowed value |
useClickOutside(target, handler)
Run a handler when clicking outside a target element. Accepts a CSS selector string or Element.
<scriptclientlang=""
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.
<scriptclientlang=""
const { isFocused, focus, blur } = useFocus('#search-input')
focus() // programmatically focus
</script>
useAsync(fn, options?)
Wrap an async function with reactive loading/error/data state.
<scriptclientlang=""
const { data, isLoading, error, execute } = useAsync(
() => fetch('/api/users').then(r => r.json()),
{ immediate: true }
)
// Re-execute later:
execute()
</script>
| Option | Type | Default | Description |
|---|---|---|---|
immediate | boolean | false | Execute immediately on creation |
Color Mode
useColorMode(options?)
Full-featured color-mode manager with system detection, localStorage persistence, cross-tab sync, and <html> class toggling.
<scriptclientlang=""
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>
| Option | Type | Default | Description |
|---|---|---|---|
storageKey | string | 'stx-color-mode' | localStorage key |
initialMode | 'light' | 'dark' | 'auto' | 'auto' | Initial mode |
darkClass | string | 'dark' | CSS class on <html> when dark |
attribute | string | — | Use data attribute instead of class (e.g. 'data-theme') |
disableTransitions | boolean | true | Prevent 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.
<scriptclientlang=""
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
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable strict mode validation |
failOnViolation | boolean | false | Throw errors (true) or warnings (false) |
allowPatterns | string[] | [] | Pattern strings to allow through |
Prohibited Patterns
When strict mode is enabled, these patterns trigger warnings/errors with suggested alternatives:
| Pattern | Suggested 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.cookie | useCookie() |
document.addEventListener() | useEventListener() or @event directives |
window.location | navigate() or useRoute() |
window.history | navigate(), goBack(), goForward() |
window.addEventListener() | useEventListener() or @event directives |
window.localStorage | useLocalStorage() |
window.sessionStorage | useSessionStorage() |
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): -->
<scriptclient
document.getElementById('myBtn') // ✗ Use useRef("myBtn") instead
window.location.href = '/about' // ✗ Use navigate('/about') instead
</script>
<!-- This is the stx way: -->
<scriptclient
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
Navigation
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
x-data) | Signals (state/derived/effect) | |
|---|---|---|
| Syntax | x-data<script client> with signals | state(), derived(), effect(), {{ }}, :attr, @event |
| TypeScript | Yes (full TS in <script> blocks) | |
| Scope | x-data="{ ... }" | Per-component via <script client> block |
| Use case | All client-side reactivity | |
| Lifecycle | x-init only | onMount, onDestroy |
| Derived state | derived(() => ...) auto-tracks | |
| Two-way binding | x-model="name" | x-model="name" (same) |
| File | reactive.ts runtime | signals.ts runtime |
Recommendation: Use <script client> with signals for all client-side reactivity. x-data is deprecated.
<!-- Signals-based reactivity -->
<scriptclient
const open = state(false)
const count = state(0)
</script>
<div>
<buttonclick="">Toggle</button>
<divshow="">Signals-managed</div>
</div>
<div>
<p>{{ count() }}</p>
<buttonclick="">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>
<buttonclick="">Click</button>
After (v2)
<script
const greeting = 'Hello'
function handleClick() { console.log('clicked') }
</script>
<div>{{ greeting }}</div>
<buttonclick="">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:
- Template is scanned for identifiers in
{{ }},:attr="",@event="",@model="",@for=""expressions - Script is scanned for top-level declarations (
const,let,var,function) - If any declarations match template references, the script is wrapped in
stx.mount()with an auto-generatedreturnstatement - Scripts with explicit
stx.mount()calls are never double-wrapped - 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
<divclass="">
<h2>{{ title }}</h2>
<p>{{ description }}</p>
</div>
</template>
<stylescoped
.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-abc123attribute - Hash is deterministic (based on file path) — same file always produces the same hash
- Non-scoped
<style>tags pass through unchanged - Supports nested selectors,
@mediaqueries,@keyframes, and comma-separated selectors
Mixing scoped and global styles:
<style
/* Global — applies everywhere */
.global-reset { margin: 0; }
</style>
<stylescoped
/* 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
<divclass="">
<nav>
<ahref=""data-stx-link>Dashboard</a>
<ahref=""data-stx-link>Jobs</a>
<ahref=""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 -->
<scriptserver
const jobs = await fetchJobs()
</script>
<h1>Jobs</h1>
@foreach(jobs as job)
<div>{{ job.name }}</div>
@endforeach
How it works:
app.stxis auto-detected in the project root (or configured viashellinstx.config.ts)- Direct browser requests get the full shell + page pre-rendered into
<slot /> - SPA navigation (
data-stx-linkclicks) 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:
| Request | Header | Server Response | Router Behavior |
|---|---|---|---|
| Direct (browser) | None | Full shell + page | Normal page load |
| SPA navigation | X-STX-Router: true | Page fragment | Inject into [data-stx-content] |
Fragment handling:
- Router detects fragment (no
<!DOCTYPE/<html>wrapper) - Extracts
<script>and<style>tags from fragment - Removes old page styles (
[data-stx-page]), injects new ones into<head> - Swaps HTML content into the content container
- Re-executes page scripts
- Dispatches
stx:navigateandstx:loadevents - 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 -->
<StxLinkto="">About</StxLink>
<!-- Dynamic path (works inside @for loops) -->
<StxLinkx-to="">View Details</StxLink>
<!-- Custom active class -->
<StxLinkto=""activeClass="">Jobs</StxLink>
<!-- Exact active class (only on exact path match) -->
<StxLinkto=""exactActiveClass="">Home</StxLink>
<!-- Prefetch on hover -->
<StxLinkto=""prefetch>Dashboard</StxLink>
<!-- With CSS class -->
<StxLinkto=""className="">Settings</StxLink>
Props:
| Prop | Default | Description |
|---|---|---|
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 |
prefetch | false | Prefetch page content on hover |
className | '' | CSS class for the element |
Link behavior:
| Element | Behavior |
|---|---|
<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 -->
<StxImagesrc=""alt="" />
<!-- With dimensions (prevents layout shift) -->
<StxImagesrc=""alt=""width=""height="" />
<!-- Eager loading (above the fold) -->
<StxImagesrc=""alt=""lazy="" />
<!-- With responsive srcset -->
<StxImagesrc=""alt=""sizes=""srcset="" />
<!-- With blur placeholder -->
<StxImagesrc=""alt=""placeholder="" />
Props:
| Prop | Default | Description |
|---|---|---|
src | — | Image URL (required) |
alt | '' | Alt text |
width | — | Image width |
height | — | Image height |
lazy | true | Lazy 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
<divclass="">
<spanclass="">{{ props.label }}</span>
<spanclass="">{{ 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-propsattribute 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.