A lightweight, declarative UI framework for building interactive web applications with WebSocket RPC. Beam provides server-driven UI updates with minimal JavaScript configuration—just add attributes to your HTML.
- WebSocket RPC - Real-time communication without HTTP overhead
- Declarative - No JavaScript needed, just HTML attributes
- Auto-discovery - Handlers are automatically found via Vite plugin
- Modals & Drawers - Built-in overlay components
- Smart Loading - Per-action loading indicators with parameter matching
- DOM Updates - Server-driven UI updates
- Real-time Validation - Validate forms as users type
- Input Watchers - Trigger actions on input/change events with debounce/throttle
- Conditional Triggers - Only trigger when conditions are met (
beam-watch-if) - Dirty Form Tracking - Track unsaved changes with indicators and warnings
- Conditional Fields - Enable/disable/show/hide fields based on other values
- Deferred Loading - Load content when scrolled into view
- Polling - Auto-refresh content at intervals
- Hungry Elements - Auto-update elements across actions
- Confirmation Dialogs - Confirm before destructive actions
- Instant Click - Trigger on mousedown for snappier UX
- Offline Detection - Show/hide content based on connection
- Navigation Feedback - Auto-highlight current page links
- Conditional Show/Hide - Toggle visibility based on form values
- Auto-submit Forms - Submit on field change
- Beam Visits - Upgrade normal SSR links into Beam-powered visits
- History Management - Push/replace browser history for actions and visits
- Placeholders - Show loading content in target
- Keep Elements - Preserve elements during updates
- Toggle - Client-side show/hide with transitions (no server)
- Dropdowns - Click-outside closing, Escape key support (no server)
- Collapse - Expand/collapse with text swap (no server)
- Class Toggle - Toggle CSS classes on elements (no server)
- Reactive State - Fine-grained reactivity for UI components (tabs, accordions, carousels)
- Server State Updates - Update named client state from server actions without swapping DOM
- Cloak - Hide elements until reactivity initializes (no flash of unprocessed content)
- Lazy Connections - Beam connects only when needed by default
- Multi-Render - Update multiple targets in a single action response
- Async Components - Full support for HonoX async components in
ctx.render() - Streaming Actions - Async generator handlers push incremental updates over WebSocket (skeleton → content, live progress, AI-style text)
npm install @benqoder/beam// vite.config.ts
import { beamPlugin } from "@benqoder/beam/vite";
export default defineConfig({
plugins: [
beamPlugin({
actions: "./actions/*.tsx",
}),
],
});// app/server.ts
import { createApp } from "honox/server";
import { beam } from "virtual:beam";
const app = createApp({
init: beam.init,
});
export default app;// app/client.ts
import "@benqoder/beam/client";// app/actions/counter.tsx
export function increment(c) {
const count = parseInt(c.req.query("count") || "0");
return <div>Count: {count + 1}</div>;
}<div id="counter">Count: 0</div>
<button beam-action="increment" beam-target="#counter">Increment</button>Actions are server functions that return HTML. They're the primary way to handle user interactions.
// app/actions/demo.tsx
export function greet(c) {
const name = c.req.query("name") || "World";
return <div>Hello, {name}!</div>;
}<button beam-action="greet" beam-data-name="Alice" beam-target="#greeting">
Say Hello
</button>
<div id="greeting"></div>Use beam-include to collect values from input elements and include them in action params. Elements are found by beam-id, id, or name (in that priority order):
<!-- Define inputs with beam-id, id, or name -->
<input beam-id="name" type="text" value="Ben" />
<input id="email" type="email" value="[email protected]" />
<input name="age" type="number" value="30" />
<input beam-id="subscribe" type="checkbox" checked />
<!-- Button includes specific inputs -->
<button
beam-action="saveUser"
beam-include="name,email,age,subscribe"
beam-data-source="form"
beam-target="#result"
>
Save
</button>
<div id="result"></div>The action receives merged params with proper type conversion:
{
"source": "form",
"name": "Ben",
"email": "[email protected]",
"age": 30,
"subscribe": true
}Type conversion:
checkbox→boolean(checked state)number/range→number- All others →
string
Two ways to open modals:
1. beam-modal attribute - Explicitly opens the action result in a modal, with optional placeholder:
<!-- Shows placeholder while loading, then replaces with action result -->
<button
beam-modal="confirmDelete"
beam-data-id="123"
beam-size="small"
beam-placeholder="<div>Loading...</div>"
>
Delete Item
</button>2. beam-action with ctx.modal() - Action decides to return a modal:
// app/actions/confirm.tsx
export function confirmDelete(
ctx: BeamContext<Env>,
{ id }: Record<string, unknown>,
) {
return ctx.modal(
<div>
<h2>Confirm Delete</h2>
<p>Are you sure you want to delete item {id}?</p>
<button beam-action="deleteItem" beam-data-id={id} beam-close>
Delete
</button>
<button beam-close>Cancel</button>
</div>,
{ size: "small" },
);
}ctx.modal() accepts JSX directly - no wrapper function needed. Options: size ('small' | 'medium' | 'large'), spacing (padding in pixels).
<button beam-action="confirmDelete" beam-data-id="123">Delete Item</button>Two ways to open drawers:
1. beam-drawer attribute - Explicitly opens in a drawer:
<button
beam-drawer="openCart"
beam-position="right"
beam-size="medium"
beam-placeholder="<div>Loading cart...</div>"
>
Open Cart
</button>2. beam-action with ctx.drawer() - Action returns a drawer:
// app/actions/cart.tsx
export function openCart(ctx: BeamContext<Env>) {
return ctx.drawer(
<div>
<h2>Shopping Cart</h2>
<div class="cart-items">{/* Cart contents */}</div>
<button beam-close>Close</button>
</div>,
{ position: "right", size: "medium" },
);
}ctx.drawer() accepts JSX directly. Options: position ('left' | 'right'), size ('small' | 'medium' | 'large'), spacing (padding in pixels).
<button beam-action="openCart">Open Cart</button>Update multiple targets in a single action response using ctx.render() with arrays:
1. Explicit targets (comma-separated)
export function refreshDashboard(ctx: BeamContext<Env>) {
return ctx.render(
[
<div class="stat-card">Visits: {visits}</div>,
<div class="stat-card">Users: {users}</div>,
<div class="stat-card">Revenue: ${revenue}</div>,
],
{ target: "#stats, #users, #revenue" },
);
}2. Auto-detect by beam-id / beam-item-id (no targets needed)
export function refreshDashboard(ctx: BeamContext<Env>) {
// Client automatically finds elements by stable identity
return ctx.render([
<div beam-id="stats">Visits: {visits}</div>,
<div beam-id="users">Users: {users}</div>,
<div beam-id="revenue">Revenue: ${revenue}</div>,
]);
}3. Mixed approach
export function updateDashboard(ctx: BeamContext<Env>) {
return ctx.render(
[
<div>Header content</div>, // Uses explicit target
<div beam-id="content">Main content</div>, // Auto-detected by beam-id
],
{ target: "#header" }, // Only first item gets explicit target
);
}Target Resolution Order:
- Explicit target from comma-separated list (by index)
- Identity from the HTML fragment's root element (
beam-idorbeam-item-id) - Frontend fallback (
beam-targeton the triggering element) - Skip if no target found
Notes:
beam-targetaccepts any valid CSS selector (e.g.#id,.class,[attr=value]). Using#idtargets is still fully supported.- Auto-targeting (step 2) uses stable element identity from the returned HTML root:
beam-id,beam-item-id, orid. - Auto-targeting intentionally does not use input
namevalues, because they are often not unique enough for safe DOM replacement. - Prefer
beam-idfor named UI regions andbeam-item-idfor repeated/list items. Use plainidwhen that already matches your markup structure. - When an explicit target is used and the server returns a single root element that has the same
beam-id/beam-item-idas the target, Beam unwraps it and swaps only the target’s inner content. This prevents accidentally nesting the component inside itself.
Exclusion: Use !selector to explicitly skip an item:
ctx.render(
[<Box1 />, <Box2 />, <Box3 />],
{ target: "#a, !#skip, #c" }, // Box2 is skipped
);ctx.render() fully supports HonoX async components:
// Async component that fetches data
async function UserCard({ userId }: { userId: string }) {
const user = await db.getUser(userId); // Async data fetch
return (
<div class="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// Use directly in ctx.render() - no wrapper needed
export function loadUser(
ctx: BeamContext<Env>,
{ id }: Record<string, unknown>,
) {
return ctx.render(<UserCard userId={id as string} />, { target: "#user" });
}
// Works with arrays too
export function loadUsers(ctx: BeamContext<Env>) {
return ctx.render(
[<UserCard userId="1" />, <UserCard userId="2" />, <UserCard userId="3" />],
{ target: "#user1, #user2, #user3" },
);
}
// Mixed sync and async
export function loadDashboard(ctx: BeamContext<Env>) {
return ctx.render([
<div>Static header</div>, // Sync
<UserCard userId="current" />, // Async
<StatsWidget />, // Async
]);
}Async components are awaited automatically - no manual Promise.resolve() or helper functions needed.
Turn any action into a streaming action by making it an async generator (async function*). Each yield pushes an update to the browser immediately — no waiting for the full response.
export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
// First yield: show skeleton immediately
yield ctx.render(<div id="profile">Loading…</div>)
// Simulate slow API call
await delay(1800)
const user = await db.getUser(id as string)
// Second yield: replace with real content
yield ctx.render(
<div id="profile">
<h3>{user.name}</h3>
<p>{user.role}</p>
</div>
)
}Use it in HTML exactly like a regular action — no special attribute needed:
<button beam-action="loadProfile" beam-include="id">Load Profile</button>
<div id="profile"></div>Skeleton → Content — yield a skeleton immediately so the user sees feedback, then yield the real content once the data is ready:
export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
yield ctx.render(<SkeletonCard id="result" />)
const user = await fetchUser(id)
yield ctx.render(<UserCard id="result" user={user} />)
}Multi-step Progress — yield after each step completes to show a live progress list:
export async function* runPipeline(ctx: BeamContext<Env>, _: Record<string, unknown>) {
const steps = ['Validating', 'Fetching', 'Processing', 'Saving']
const done: string[] = []
for (const step of steps) {
yield ctx.render(<Pipeline done={done} current={step} pending={steps.slice(done.length + 1)} id="pipeline" />)
await runStep(step)
done.push(step)
}
yield ctx.render(<Pipeline done={done} complete id="pipeline" />)
}AI-style Text Streaming — accumulate text and re-render on each chunk for a typewriter effect:
export async function* streamText(ctx: BeamContext<Env>, _: Record<string, unknown>) {
let text = ''
for (const word of words) {
text += (text ? ' ' : '') + word
await delay(120)
yield ctx.render(<p id="output">{text}<span class="cursor">▌</span></p>)
}
yield ctx.render(<p id="output">{text}</p>) // remove cursor
}Streaming into Modals and Drawers — yield ctx.modal() or ctx.drawer() calls. The first yield opens the overlay; subsequent yields update its content:
export async function* openProfileModal(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
yield ctx.modal(<SkeletonProfile />, { size: 'md' }) // opens modal with skeleton
const user = await fetchUser(id)
yield ctx.modal(<UserProfile user={user} />, { size: 'md' }) // swaps in real content
}<button beam-modal="openProfileModal" beam-include="id">View Profile</button>- A regular action handler returns a single value → one DOM update.
- An async generator handler yields multiple values → one DOM update per yield, streamed over the WebSocket as each chunk is ready.
- The client processes chunks in order; each chunk is a full
ActionResponse(the same object a regular action returns). - No special HTML attributes are needed — the server detects async generators automatically.
| Attribute | Description | Example |
|---|---|---|
beam-action |
Action name to call | beam-action="increment" |
beam-target |
CSS selector for where to render response | beam-target="#counter" |
beam-data-* |
Pass data to the action | beam-data-id="123" |
beam-include |
Include values from inputs by beam-id, id, or name | beam-include="name,email,age" |
beam-swap |
How to swap content: replace, append, prepend, delete |
beam-swap="replace" |
beam-confirm |
Show confirmation dialog before action | beam-confirm="Delete this item?" |
beam-confirm-prompt |
Require typing text to confirm | beam-confirm-prompt="Type DELETE|DELETE" |
beam-instant |
Trigger on mousedown instead of click | beam-instant |
beam-disable |
Disable element(s) during request | beam-disable or beam-disable="#btn" |
beam-placeholder |
Show placeholder in target while loading | beam-placeholder="<p>Loading...</p>" |
beam-push |
Push URL to browser history after action | beam-push="/new-url" |
beam-replace |
Replace current URL in history | beam-replace="?page=2" |
Swap notes:
replacereplacestarget.innerHTML(no DOM diff), then tries to preserve UX:- Keeps focused input caret/selection when possible.
- Reinserts elements marked with
beam-keep(matched bybeam-id,beam-item-id,id, or inputname). - If Alpine.js is present on the page, initializes any newly inserted DOM (
Alpine.initTree).
Swap transitions (optional):
Add beam-swap-transition on the target element to animate after swaps:
<div id="results" beam-swap-transition="fade"></div>Supported values: fade, slide, scale.
| Attribute | Description | Example |
|---|---|---|
beam-modal |
Action to call and display result in modal | beam-modal="editUser" |
beam-drawer |
Action to call and display result in drawer | beam-drawer="openCart" |
beam-size |
Size for modal/drawer: small, medium, large |
beam-size="large" |
beam-position |
Drawer position: left, right |
beam-position="left" |
beam-placeholder |
HTML to show while loading | beam-placeholder="<p>Loading...</p>" |
beam-close |
Close the current modal/drawer when clicked | beam-close |
Modals and drawers can also be returned from beam-action using context helpers:
// Modal with options
return ctx.modal(render(<MyModal />), { size: "large", spacing: 20 });
// Drawer with options
return ctx.drawer(render(<MyDrawer />), { position: "left", size: "medium" });| Attribute | Description | Example |
|---|---|---|
beam-action |
Action to call on submit (on <form>) |
<form beam-action="saveUser"> |
beam-reset |
Reset form after successful submit | beam-reset |
| Attribute | Description | Example |
|---|---|---|
beam-loading-for |
Show element while action is loading | beam-loading-for="saveUser" |
beam-loading-for="*" |
Show for any loading action | beam-loading-for="*" |
beam-loading-data-* |
Match specific parameters | beam-loading-data-id="123" |
beam-loading-class |
Add class while loading | beam-loading-class="opacity-50" |
beam-loading-remove |
Hide element while NOT loading | beam-loading-remove |
| Attribute | Description | Example |
|---|---|---|
beam-validate |
Target selector to update with validation | beam-validate="#email-error" |
beam-watch |
Event to trigger validation: input, change |
beam-watch="input" |
beam-debounce |
Debounce delay in milliseconds | beam-debounce="300" |
| Attribute | Description | Example |
|---|---|---|
beam-watch |
Event to trigger action: input, change |
beam-watch="input" |
beam-debounce |
Debounce delay in milliseconds | beam-debounce="300" |
beam-throttle |
Throttle interval in milliseconds (alternative to debounce) | beam-throttle="100" |
beam-watch-if |
Condition that must be true to trigger | beam-watch-if="value.length >= 3" |
beam-cast |
Cast input value: number, integer, boolean, trim |
beam-cast="number" |
beam-loading-class |
Add class to input while request is in progress | beam-loading-class="loading" |
beam-keep |
Prevent element from being replaced during updates | beam-keep |
| Attribute | Description | Example |
|---|---|---|
beam-dirty-track |
Enable dirty tracking on a form | <form beam-dirty-track> |
beam-dirty-indicator |
Show element when form is dirty | beam-dirty-indicator="#my-form" |
beam-dirty-class |
Toggle class instead of visibility | beam-dirty-class="has-changes" |
beam-warn-unsaved |
Warn before leaving page with unsaved changes | <form beam-warn-unsaved> |
beam-revert |
Button to revert form to original values | beam-revert="#my-form" |
beam-show-if-dirty |
Show element when form is dirty | beam-show-if-dirty="#my-form" |
beam-hide-if-dirty |
Hide element when form is dirty | beam-hide-if-dirty="#my-form" |
| Attribute | Description | Example |
|---|---|---|
beam-enable-if |
Enable field when condition is true | beam-enable-if="#subscribe:checked" |
beam-disable-if |
Disable field when condition is true | beam-disable-if="#country[value='']" |
beam-visible-if |
Show field when condition is true | beam-visible-if="#source[value='other']" |
beam-hidden-if |
Hide field when condition is true | beam-hidden-if="#premium:checked" |
beam-required-if |
Make field required when condition is true | beam-required-if="#business:checked" |
| Attribute | Description | Example |
|---|---|---|
beam-defer |
Load content when element enters viewport | beam-defer |
beam-action |
Action to call (used with beam-defer) |
beam-action="loadComments" |
| Attribute | Description | Example |
|---|---|---|
beam-poll |
Enable polling on this element | beam-poll |
beam-interval |
Poll interval in milliseconds | beam-interval="5000" |
beam-action |
Action to call on each poll | beam-action="getStatus" |
| Attribute | Description | Example |
|---|---|---|
beam-hungry |
Auto-update when any response contains matching ID | beam-hungry |
| Attribute | Description | Example |
|---|---|---|
beam-touch |
Update additional elements (on server response) | beam-touch="#sidebar,#footer" |
| Attribute | Description | Example |
|---|---|---|
beam-optimistic |
Immediately update with this HTML before response | beam-optimistic="<div>Saving...</div>" |
| Attribute | Description | Example |
|---|---|---|
beam-preload |
Preload on hover: hover, mount |
beam-preload="hover" |
beam-cache |
Cache duration in seconds | beam-cache="60" |
| Attribute | Description | Example |
|---|---|---|
beam-infinite |
Load more when scrolled near bottom (auto-trigger) | beam-infinite |
beam-load-more |
Load more on click (manual trigger) | beam-load-more |
beam-action |
Action to call for next page | beam-action="loadMore" |
beam-item-id |
Unique ID for list items (deduplication + fresh data sync) | beam-item-id={item.id} |
| Attribute | Description | Example |
|---|---|---|
beam-nav |
Mark container as navigation (children get .beam-current) |
<nav beam-nav> |
beam-nav-exact |
Only match exact URL paths | beam-nav-exact |
| Attribute | Description | Example |
|---|---|---|
beam-offline |
Show element when offline, hide when online | beam-offline |
beam-offline-class |
Toggle class instead of visibility | beam-offline-class="offline-warning" |
beam-offline-disable |
Disable element when offline | beam-offline-disable |
| Element / Attribute | Description |
|---|---|
<meta name="beam-reconnect-interval" content="5000"> |
Fixed retry interval (ms) after initial backoff |
beam-disconnected |
Show element while disconnected, hide on reconnect |
<meta name="beam-auto-connect" content="true"> |
Explicitly opt into eager Beam connection |
| Attribute | Description | Example |
|---|---|---|
beam-switch |
Watch this field and control target elements | beam-switch=".options" |
beam-show-for |
Show when switch value matches | beam-show-for="premium" |
beam-hide-for |
Hide when switch value matches | beam-hide-for="free" |
beam-enable-for |
Enable when switch value matches | beam-enable-for="admin" |
beam-disable-for |
Disable when switch value matches | beam-disable-for="guest" |
| Attribute | Description | Example |
|---|---|---|
beam-autosubmit |
Submit form when any field changes | beam-autosubmit |
beam-debounce |
Debounce delay in milliseconds | beam-debounce="300" |
| Attribute | Description | Example |
|---|---|---|
beam-boost |
Upgrade descendant same-origin links into Beam SSR visits | <main beam-boost> |
beam-boost-off |
Exclude specific links from Beam visits | beam-boost-off |
beam-visit |
Explicitly mark a link as a Beam visit | <a beam-visit href> |
beam-patch |
Visit with patch-style scroll semantics | <a beam-patch href> |
beam-navigate |
Visit with navigation-style scroll semantics | <a beam-navigate href> |
| Attribute | Description | Example |
|---|---|---|
beam-keep |
Preserve element during DOM updates | <video beam-keep> |
Beam supports a single client-side UI model: reactive state + declarative bindings. Use beam-state to define state, beam-show / beam-class / beam-text / beam-attr-* to bind UI, and beam-click / beam-model / beam-state-toggle to mutate state.
Fine-grained reactivity for UI components (carousels, tabs, accordions) without server round-trips.
| Attribute | Description | Example |
|---|---|---|
beam-state |
Declare reactive state (JSON, key-value, or simple value) | beam-state="tab: 0; total: 5" |
beam-id |
Name the state for cross-component access | beam-id="cart" |
beam-state-ref |
Reference a named state from elsewhere | beam-state-ref="cart" |
beam-init |
Run JS expression once after state is initialized | beam-init="setInterval(() => { index = (index+1) % total }, 3000)" |
beam-cloak |
Hide element until its reactive scope is ready | <div beam-state="open: false" beam-cloak> |
beam-text |
Bind text content to expression | beam-text="count" |
beam-attr-* |
Bind any attribute to expression | beam-attr-disabled="count === 0" |
beam-show |
Show/hide element based on expression | beam-show="open" |
beam-class |
Toggle classes (simplified or JSON syntax) | beam-class="active: tab === 0" |
beam-click |
Click handler that mutates state | beam-click="open = !open" |
beam-state-toggle |
Toggle (or set) a state property (sugar) | beam-state-toggle="open" |
beam-model |
Two-way binding for inputs | beam-model="firstName" |
Control how content is inserted into the target element:
| Mode | Description |
|---|---|
replace |
Replace target HTML (default) while preserving focus and beam-keep elements when possible |
append |
Add to the end of target |
prepend |
Add to the beginning of target |
delete |
Remove the target element |
<!-- Append new items to a list -->
<button beam-action="addItem" beam-swap="append" beam-target="#items">
Add Item
</button>
<!-- Replace update -->
<button beam-action="refresh" beam-swap="replace" beam-target="#content">
Refresh
</button>Show a loader for any action:
<div beam-loading-for="*" class="spinner">Loading...</div>Show a loader only for specific actions:
<button beam-action="save">
Save
<span beam-loading-for="save" class="spinner"></span>
</button>Show loading state only when parameters match:
<div class="item">
<span>Item 1</span>
<span beam-loading-for="deleteItem" beam-loading-data-id="1">
Deleting...
</span>
<button beam-action="deleteItem" beam-data-id="1">Delete</button>
</div>Toggle a class instead of showing/hiding:
<div beam-loading-for="save" beam-loading-class="opacity-50">
Content that fades while saving
</div>Validate form fields as the user types:
<form beam-action="submitForm" beam-target="#result">
<input
name="email"
beam-validate="#email-error"
beam-watch="input"
beam-debounce="300"
/>
<div id="email-error"></div>
<button type="submit">Submit</button>
</form>The action receives _validate parameter indicating which field triggered validation:
export function submitForm(c) {
const data = await c.req.parseBody();
const validateField = data._validate;
if (validateField === "email") {
// Return just the validation feedback
if (data.email === "[email protected]") {
return <div class="error">Email already taken</div>;
}
return <div class="success">Email available</div>;
}
// Full form submission
return <div>Form submitted!</div>;
}Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.
<!-- Live search with debounce -->
<input
name="q"
placeholder="Search..."
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
/>
<div id="results"></div>Use beam-throttle for real-time updates (like range sliders) where you want periodic updates:
<!-- Range slider with throttle - updates every 100ms while dragging -->
<input
type="range"
name="price"
beam-action="updatePrice"
beam-target="#price-display"
beam-watch="input"
beam-throttle="100"
/>
<!-- Search with debounce - waits 300ms after user stops typing -->
<input
name="q"
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
/>Only trigger action when a condition is met:
<!-- Only search when 3+ characters are typed -->
<input
name="q"
placeholder="Type 3+ chars to search..."
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-watch-if="value.length >= 3"
beam-debounce="300"
/>The condition has access to value (current input value) and this (the element).
Cast input values before sending to the server:
<!-- Send as number instead of string -->
<input
type="range"
name="quantity"
beam-action="updateQuantity"
beam-cast="number"
beam-watch="input"
/>Cast types:
number- Parse as floatinteger- Parse as integerboolean- Convert "true"/"1"/"yes" to truetrim- Trim whitespace
Add a class to the input while the request is in progress:
<input
name="q"
placeholder="Search..."
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-loading-class="input-loading"
/>
<style>
.input-loading {
border-color: blue;
animation: pulse 1s infinite;
}
</style>Use beam-keep to prevent an element from being replaced during updates. This keeps the element exactly as-is, preserving its state (focus, value, etc.):
<input
name="bio"
beam-action="validateBio"
beam-target="#bio-feedback"
beam-watch="input"
beam-keep
/>Since the input isn't replaced, focus and cursor position are naturally preserved.
Beam matches kept elements by stable identity in this order:
beam-idbeam-item-idid- unique form control
nameas a best-effort fallback
For the most reliable behavior, give kept elements a beam-id, beam-item-id, or id. If multiple inputs share the same name, Beam will not preserve by name alone.
Use cases:
beam-id: shared UI regions like badges, panels, counters, and named state scopesbeam-item-id: repeated records in feeds, tables, carts, and paginated listsid: one-off DOM anchors when the element already has a stable page-level ID
Trigger action when the user leaves the field:
<input
name="username"
beam-action="saveField"
beam-data-field="username"
beam-target="#save-status"
beam-watch="change"
beam-keep
/>
<div id="save-status">Not saved yet</div>Track form changes and warn users before losing unsaved work.
<form id="profile-form" beam-dirty-track>
<input name="username" value="johndoe" />
<input name="email" value="[email protected]" />
<button type="submit">Save</button>
</form>The form gets a beam-dirty attribute when modified.
Show an indicator when the form has unsaved changes:
<h2>
Profile Settings
<span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
</h2>
<form id="profile-form" beam-dirty-track>
<!-- form fields -->
</form>
<style>
[beam-dirty-indicator] {
display: none;
color: orange;
}
</style>Add a button to restore original values:
<form id="profile-form" beam-dirty-track>
<input name="username" value="johndoe" />
<input name="email" value="[email protected]" />
<button
type="button"
beam-revert="#profile-form"
beam-show-if-dirty="#profile-form"
>
Revert Changes
</button>
<button type="submit">Save</button>
</form>The revert button only shows when the form is dirty.
Warn users before navigating away with unsaved changes:
<form beam-dirty-track beam-warn-unsaved>
<input name="important-data" />
<button type="submit">Save</button>
</form>The browser will show a confirmation dialog if the user tries to close the tab or navigate away.
Show/hide elements based on dirty state:
<form id="settings" beam-dirty-track>
<!-- Show when dirty -->
<div beam-show-if-dirty="#settings" class="warning">
You have unsaved changes
</div>
<!-- Hide when dirty -->
<div beam-hide-if-dirty="#settings">All changes saved</div>
</form>Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.
<label>
<input type="checkbox" id="subscribe" name="subscribe" />
Subscribe to newsletter
</label>
<!-- Enabled only when checkbox is checked -->
<input
type="email"
name="email"
placeholder="Enter your email..."
beam-enable-if="#subscribe:checked"
disabled
/><select name="source" id="source">
<option value="">-- Select --</option>
<option value="google">Google</option>
<option value="friend">Friend</option>
<option value="other">Other</option>
</select>
<!-- Only visible when "other" is selected -->
<div beam-visible-if="#source[value='other']">
<label>Please specify</label>
<input type="text" name="source-other" />
</div><label>
<input type="checkbox" id="business" name="is-business" />
This is a business account
</label>
<!-- Required only when checkbox is checked -->
<input
type="text"
name="company"
placeholder="Company name"
beam-required-if="#business:checked"
/>Conditions support:
:checked- Checkbox/radio is checked:disabled- Element is disabled:empty- Input has no value[value='x']- Input value equals 'x'[value!='x']- Input value not equals 'x'[value>'5']- Numeric comparison
<!-- Enable when country is selected -->
<select beam-disable-if="#country[value='']" name="state">
<!-- Show when amount is over 100 -->
<div beam-visible-if="#amount[value>'100']">
Large order discount applied!
</div>
</select>Load content only when it enters the viewport:
<div beam-defer beam-action="loadComments" class="placeholder">
Loading comments...
</div>The action is called once when the element becomes visible (with 100px margin).
Auto-refresh content at regular intervals:
<div beam-poll beam-interval="5000" beam-action="getNotifications">
<!-- Updated every 5 seconds -->
</div>Polling automatically stops when the element is removed from the DOM.
Elements marked with beam-hungry automatically update whenever any action returns HTML with matching stable identity:
<!-- This badge updates when any action returns beam-id="cart-count" -->
<span beam-id="cart-count" beam-hungry>0</span>
<!-- Clicking this updates both #cart-result AND the hungry badge -->
<button beam-action="addToCart" beam-target="#cart-result">Add to Cart</button>export function addToCart(c) {
const cartCount = getCartCount() + 1;
return (
<>
<div>Item added to cart!</div>
{/* This updates the hungry element */}
<span beam-id="cart-count">{cartCount}</span>
</>
);
}Hungry matching checks identity in this order:
beam-idbeam-item-idid
For shared UI regions, prefer beam-id. For repeated records, prefer beam-item-id.
Update multiple elements from a single action using beam-touch:
export function updateDashboard(c) {
return (
<>
<div>Main content updated</div>
<div beam-touch="#sidebar">New sidebar content</div>
<div beam-touch="#notifications">3 new notifications</div>
</>
);
}Require user confirmation before destructive actions:
<!-- Simple confirmation -->
<button beam-action="deletePost" beam-confirm="Delete this post?">
Delete
</button>
<!-- Require typing to confirm (for high-risk actions) -->
<button
beam-action="deleteAccount"
beam-confirm="This will permanently delete your account"
beam-confirm-prompt="Type DELETE to confirm|DELETE"
>
Delete Account
</button>Trigger actions on mousedown instead of click for ~100ms faster response:
<button beam-action="navigate" beam-instant>Next Page</button>Prevent double-submissions by disabling elements during requests:
<!-- Disable the button itself -->
<button beam-action="save" beam-disable>Save</button>
<!-- Disable specific elements -->
<form beam-action="submit" beam-disable="#submit-btn, .form-inputs">
<input class="form-inputs" name="email" />
<button id="submit-btn" type="submit">Submit</button>
</form>Show loading content in the target while waiting for response:
<!-- Inline HTML placeholder -->
<button
beam-action="loadContent"
beam-target="#content"
beam-placeholder="<div class='skeleton'>Loading...</div>"
>
Load Content
</button>
<!-- Reference a template -->
<button
beam-action="loadContent"
beam-target="#content"
beam-placeholder="#loading-template"
>
Load Content
</button>
<template id="loading-template">
<div class="skeleton-loader">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</template>Automatically highlight current page links:
<nav beam-nav>
<a href="/home">Home</a>
<!-- Gets .beam-current when on /home -->
<a href="/products">Products</a>
<!-- Gets .beam-current when on /products/* -->
<a href="/about" beam-nav-exact>About</a>
<!-- Only exact match -->
</nav>Links also get .beam-active class while loading.
CSS classes:
.beam-current- Link matches current URL.beam-active- Action is in progress
Show/hide content based on connection status:
<!-- Show warning when offline -->
<div beam-offline class="offline-banner">
You are offline. Changes won't be saved.
</div>
<!-- Disable buttons when offline -->
<button beam-action="save" beam-offline-disable>Save</button>The body also gets .beam-offline class when disconnected.
Beam automatically reconnects when the WebSocket drops (e.g. server restart, network blip). No configuration required — it works out of the box.
| Attempt | Delay |
|---|---|
| 1st | 1s |
| 2nd | 3s |
| 3rd | 5s |
| 4th+ | 5s (configurable) |
After the first three stepped attempts, Beam retries indefinitely at a fixed interval until the connection is restored.
Override the fixed interval with a <meta> tag in your <head>:
<!-- Retry every 10 seconds after the initial backoff steps -->
<meta name="beam-reconnect-interval" content="10000">The value is in milliseconds. Values below 1000ms are ignored (minimum 1s).
Show UI while disconnected using beam-disconnected:
<div beam-disconnected style="display:none" class="banner">
Reconnecting...
</div>The element is shown automatically on disconnect and hidden on reconnect. The body also receives the .beam-disconnected class while offline.
| Event | Fires when |
|---|---|
beam:disconnected |
WebSocket connection drops |
beam:reconnected |
Connection successfully restored |
window.addEventListener('beam:reconnected', () => {
console.log('Back online!')
})Toggle element visibility based on form field values (no server round-trip):
<select name="plan" beam-switch=".plan-options">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<div class="plan-options" beam-show-for="free">
Free plan: 5 projects, 1GB storage
</div>
<div class="plan-options" beam-show-for="pro">
Pro plan: Unlimited projects, 100GB storage
</div>
<div class="plan-options" beam-show-for="enterprise">
Enterprise: Custom limits, dedicated support
</div>
<!-- Enable/disable based on selection -->
<button class="plan-options" beam-enable-for="pro,enterprise">
Advanced Settings
</button>Automatically submit forms when fields change (great for filters):
<form
beam-action="filterProducts"
beam-target="#products"
beam-autosubmit
beam-debounce="300"
>
<input name="search" placeholder="Search..." />
<select name="category">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select name="sort">
<option value="newest">Newest</option>
<option value="price">Price</option>
</select>
</form>
<div id="products">
<!-- Results update automatically as user changes filters -->
</div>Upgrade regular SSR links into Beam-powered visits while keeping normal navigation as the fallback:
<!-- Boost all eligible links in a container -->
<main beam-boost>
<a href="/page1">Page 1</a>
<!-- Uses Beam visit -->
<a href="/page2">Page 2</a>
<!-- Uses Beam visit -->
<a href="https://external.com">External</a>
<!-- Automatically left alone -->
<a href="/download/report.pdf" beam-boost-off>Download</a>
<!-- Explicitly opt out -->
</main>
<!-- Or opt in per link -->
<a href="/about" beam-visit beam-target="#content">About</a>
<a href="/products?page=2" beam-patch beam-target="#content">Next Page</a>
<a href="/login" beam-navigate beam-target="#content">Login</a>Visited links:
- render the real SSR route internally through Beam
- update the specified target (default: the nearest
[beam-boost]shell, otherwisebody) - update browser history and page title
- prefetch on hover/touch intent
- fall back to normal hard navigation when Beam cannot safely apply the visit
Beam automatically bypasses visits for:
- external links
- download links
- links with
target="_blank" - modifier-click / middle-click navigation
- links marked with
beam-boost-off
Current scope:
- links are fully supported
- forms are intentionally not part of the visit lifecycle yet
Fallback behavior:
- if the route returns a redirect, non-HTML response, asset mismatch, opt-out signal, or cookie-writing response, Beam falls back to normal browser navigation so session/auth behavior stays correct
Update browser URL after actions:
<!-- Push new URL to history -->
<button beam-action="loadTab" beam-data-tab="settings" beam-push="/settings">
Settings
</button>
<!-- Replace current URL (no new history entry) -->
<button beam-action="filter" beam-data-page="2" beam-replace="?page=2">
Page 2
</button>Preserve specific elements during DOM updates (useful for video players, animations):
<div id="content">
<!-- This video keeps playing during updates -->
<video beam-keep src="video.mp4" autoplay></video>
<!-- This content gets updated -->
<div id="comments">
<!-- Comments here -->
</div>
</div>Beam uses a single client-side UI model: reactive state + declarative bindings.
Fine-grained reactivity for UI components like carousels, tabs, accordions, and modals. All declarative via HTML attributes.
1. Simple value with beam-id (property name comes from beam-id):
<div beam-state="false" beam-id="open">
<button type="button" beam-state-toggle="open">Toggle</button>
<div beam-show="open">Content</div>
</div>
<div beam-state="0" beam-id="count">
<button beam-click="count++">
Clicked <span beam-text="count"></span> times
</button>
</div>2. Key-value pairs (semicolon-separated):
<div beam-state="tab: 0; total: 5">
<button beam-click="tab = (tab + 1) % total">Next</button>
<span beam-text="tab + 1"></span> of <span beam-text="total"></span>
</div>
<div beam-state="name: 'World'; greeting: 'Hello'">
<span beam-text="greeting + ', ' + name + '!'"></span>
</div>3. JSON (for arrays and nested objects):
<div beam-state='{"items": [1, 2, 3], "config": {"enabled": true}}'>...</div>1. Simplified syntax (no JSON required):
<!-- Single class -->
<button beam-class="active: tab === 0">Tab 1</button>
<!-- Multiple classes (semicolon-separated) -->
<div
beam-class="text-red: hasError; text-green: !hasError; bold: important"
></div>2. Multiple classes with one condition (quote the class names):
<div
beam-class="'bg-green text-white shadow-lg': isActive; 'bg-gray text-dark': !isActive"
></div>3. JSON (backward compatible):
<button beam-class="{ active: tab === 0, highlight: selected }">
<div beam-class="{ 'text-green italic': !hasError, bold: important }"></div>
</button><!-- Accordion -->
<div beam-state="open: false">
<button beam-click="open = !open">Toggle</button>
<div beam-show="open">
<p>Expanded content here...</p>
</div>
</div>
<!-- Tabs with simplified beam-class -->
<div beam-state="tab: 0">
<button beam-click="tab = 0" beam-class="active: tab === 0">Tab 1</button>
<button beam-click="tab = 1" beam-class="active: tab === 1">Tab 2</button>
<button beam-click="tab = 2" beam-class="active: tab === 2">Tab 3</button>
<div beam-show="tab === 0">Content for Tab 1</div>
<div beam-show="tab === 1">Content for Tab 2</div>
<div beam-show="tab === 2">Content for Tab 3</div>
</div>
<!-- Carousel (manual) -->
<div beam-state="slide: 0; total: 5">
<button beam-click="slide = (slide - 1 + total) % total">← Prev</button>
<span beam-text="slide + 1"></span> / <span beam-text="total"></span>
<button beam-click="slide = (slide + 1) % total">Next →</button>
</div>
<!-- Auto-play carousel (beam-init starts the interval after state is ready) -->
<div
beam-state="slide: 0; total: 5"
beam-init="setInterval(() => { slide = (slide + 1) % total }, 3000)"
>
<div class="overflow-hidden">
<div
class="flex transition-transform duration-500"
beam-attr-style="'transform: translateX(-' + (slide * 100) + '%)'"
>
<!-- slides here, each min-w-full -->
</div>
</div>
<button beam-click="slide = (slide - 1 + total) % total">← Prev</button>
<button beam-click="slide = (slide + 1) % total">Next →</button>
</div>
<!-- Counter with attribute binding -->
<div beam-state="count: 0">
<button beam-click="count--" beam-attr-disabled="count === 0">-</button>
<span beam-text="count"></span>
<button beam-click="count++">+</button>
</div>
<!-- Status indicator with multiple classes -->
<div beam-state="status: 'idle'">
<div
beam-class="status-idle: status === 'idle'; status-loading: status === 'loading'; status-error: status === 'error'"
>
<span beam-text="status"></span>
</div>
</div>beam-init runs a JavaScript expression once after the state scope is fully initialized. Use it for setInterval (auto-play), setTimeout, or any setup that needs access to the reactive state.
<!-- Auto-play carousel -->
<div
beam-state="slide: 0; total: 4"
beam-init="setInterval(() => { slide = (slide + 1) % total }, 3000)"
>
...
</div>
<!-- Derived initial value -->
<div
beam-state="count: 0; doubled: 0"
beam-init="doubled = count * 2"
>
<span beam-text="doubled"></span>
</div>Scoping: beam-init must be on the same element as beam-state (or on an element inside a scope). The expression runs with the same reactive state context as all other directives in the scope.
Note: beam-init only works inside a beam-state scope — it has no effect on elements without a state ancestor.
beam-cloak hides an element until its reactive scope has fully initialized. Without it, elements with beam-show may briefly appear before Beam processes them.
Add it to the same element as beam-state. Beam removes the attribute automatically after setup.
<!-- Without beam-cloak: the "Dropdown" content may flash visible on load -->
<!-- With beam-cloak: hidden until reactivity is ready, then shown correctly -->
<div beam-state="open: false" beam-cloak>
<button beam-state-toggle="open">Toggle</button>
<div beam-show="open">Dropdown content</div>
</div>Beam ships a CSS rule that does the hiding:
[beam-cloak] { display: none !important; }This rule is included in @benqoder/beam/styles. If you are not importing the beam stylesheet, add it yourself.
Share state between different parts of the page. Named states (with beam-id) persist across server-driven updates:
<!-- Cart state defined once -->
<div beam-state="count: 0" beam-id="cart">
Cart: <span beam-text="count"></span> items
</div>
<!-- Add to cart button elsewhere -->
<button beam-state-ref="cart" beam-click="count++">Add to Cart</button>Note: Named states persist when the DOM is updated by beam-action. This means reactive state is preserved even when server actions update the page.
Server actions can update existing named state directly, without returning HTML. This is useful when the UI already has a beam-state scope and you only want to change its data.
Use ctx.state(id, value) for a single state update:
export function refreshCart(ctx: BeamContext<Env>) {
return ctx.state("cart", {
count: 3,
total: 29.99,
});
}Use ctx.state({ ... }) to update multiple named states in one response:
export function syncDashboard(ctx: BeamContext<Env>) {
return ctx.state({
cart: { count: 3, total: 29.99 },
notifications: 7,
});
}<div beam-state="count: 0; total: 0" beam-id="cart">
Cart: <span beam-text="count"></span> items
Total: $<span beam-text="total.toFixed(2)"></span>
</div>
<div beam-state="0" beam-id="notifications">
Notifications: <span beam-text="notifications"></span>
</div>
<button beam-action="refreshCart">Refresh Cart</button>
<button beam-action="syncDashboard">Sync Everything</button>Behavior:
- Updates target existing named states by
beam-id - Object payloads shallow-merge into object state
- Primitive payloads replace simple named values
- Elements using
beam-state-refupdate automatically - Missing state IDs are ignored with a console warning
Access reactive state programmatically:
// Get named state
const cartState = beam.getState("cart");
cartState.count++; // Triggers reactive updates
// Get state by element
const el = document.querySelector("[beam-state]");
const state = beam.getState(el);
// Batch multiple updates
beam.batch(() => {
state.a = 1;
state.b = 2;
});
// Apply a named state update programmatically
beam.updateState("cart", { count: 4, total: 39.99 });The reactivity system can be used independently without the Beam WebSocket server:
// Just import reactivity - no server connection needed
import "@benqoder/beam/reactivity";This is useful for:
- Static sites that don't need server communication
- Adding reactivity to existing projects
- Using with other frameworks
The API is exposed on window.beamReactivity:
// When using standalone
const state = beamReactivity.getState("my-state");
state.count++;Or import the included CSS (swap transitions, modal/drawer styles):
import "@benqoder/beam/styles";
// or
import "@benqoder/beam/beam.css";Before (Alpine.js):
<div x-data="{ open: false }">
<button @click="open = !open">Menu</button>
<div x-show="open" x-cloak>Content</div>
</div>After (Beam):
<div beam-state='{"open": false}' beam-cloak>
<button type="button" beam-state-toggle="open">Menu</button>
<div beam-show="open">Content</div>
</div>
beam-cloakis the Beam equivalent ofx-cloak— it hides the element until reactivity has initialized, preventing a flash of visible content.
Before (Alpine.js dropdown):
<div x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open">Account</button>
<div x-show="open">Dropdown</div>
</div>After (Beam):
<div beam-state='{"open": false}'>
<button type="button" beam-state-toggle="open">Account</button>
<div beam-show="open">Dropdown</div>
</div>Creates a Beam instance with handlers:
import { createBeam } from "@benqoder/beam";
const beam = createBeam<Env>({
actions: { increment, decrement, openModal, openCart },
});Utility to render JSX to HTML string:
import { render } from '@benqoder/beam'
const html = render(<div>Hello</div>)beamPlugin({
// Glob pattern for action handler files (must start with '/' for virtual modules)
actions: "/app/actions/*.tsx", // default
});import type { ActionHandler, ActionResponse, BeamContext } from '@benqoder/beam'
import { render } from '@benqoder/beam'
// Action that returns HTML string
const myAction: ActionHandler<Env> = async (ctx, params) => {
return '<div>Hello</div>'
}
// Action that returns ActionResponse with modal
const openModal: ActionHandler<Env> = async (ctx, params) => {
return ctx.modal(render(<div>Modal content</div>), { size: 'medium' })
}
// Action that returns ActionResponse with drawer
const openDrawer: ActionHandler<Env> = async (ctx, params) => {
return ctx.drawer(render(<div>Drawer content</div>), { position: 'right' })
}Add to your app/vite-env.d.ts:
/// <reference types="@benqoder/beam/virtual" />// actions/todos.tsx
let todos = ["Learn Beam", "Build something"];
export function addTodo(c) {
const text = c.req.query("text");
if (text) todos.push(text);
return <TodoList todos={todos} />;
}
export function deleteTodo(c) {
const index = parseInt(c.req.query("index"));
todos.splice(index, 1);
return <TodoList todos={todos} />;
}
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, i) => (
<li>
{todo}
<button
beam-action="deleteTodo"
beam-data-index={i}
beam-target="#todos"
>
Delete
</button>
</li>
))}
</ul>
);
}<form beam-action="addTodo" beam-target="#todos" beam-reset>
<input name="text" placeholder="New todo" />
<button type="submit">Add</button>
</form>
<div id="todos">
<!-- Todo list renders here -->
</div>// actions/search.tsx
export function search(c) {
const query = c.req.query("q") || "";
const results = searchDatabase(query);
return (
<ul>
{results.map((item) => (
<li>{item.name}</li>
))}
</ul>
);
}<input
beam-action="search"
beam-target="#results"
beam-watch="input"
beam-debounce="300"
name="q"
placeholder="Search..."
/>
<div id="results"></div>// actions/cart.tsx
let cart = [];
export function addToCart(c) {
const product = c.req.query("product");
cart.push(product);
return (
<>
<div>Added {product} to cart!</div>
<span id="cart-badge">{cart.length}</span>
</>
);
}<span id="cart-badge" beam-hungry>0</span>
<button
beam-action="addToCart"
beam-data-product="Widget"
beam-target="#message"
>
Add Widget
</button>
<div id="message"></div>Beam provides automatic session management with a simple ctx.session API. No boilerplate middleware required.
- Enable sessions in vite.config.ts:
beamPlugin({
actions: "/app/actions/*.tsx",
session: true, // Enable with defaults (cookie storage)
});- Add SESSION_SECRET to wrangler.toml:
[vars]
SESSION_SECRET = "your-secret-key-change-in-production"- Use in actions:
// app/actions/cart.tsx
export async function addToCart(ctx: BeamContext<Env>, data) {
const cart = await ctx.session.get<CartItem[]>('cart') || []
cart.push({ productId: data.productId, qty: data.qty })
await ctx.session.set('cart', cart)
return <CartBadge count={cart.length} />
}- Use in routes:
// app/routes/products/index.tsx
export default createRoute(async (c) => {
const { session } = c.get('beam')
const cart = await session.get<CartItem[]>('cart') || []
return c.html(
<Layout cartCount={cart.length}>
<ProductList />
</Layout>
)
})// Get a value (returns null if not set)
const cart = await ctx.session.get<CartItem[]>("cart");
// Set a value
await ctx.session.set("cart", [{ productId: "123", qty: 1 }]);
// Delete a value
await ctx.session.delete("cart");Session data is stored in a signed cookie. Good for small data (~4KB limit).
beamPlugin({
session: true, // Uses cookie storage
});
// Or with custom options:
beamPlugin({
session: {
secretEnvKey: "MY_SECRET", // Default: 'SESSION_SECRET'
cookieName: "my_sid", // Default: 'beam_sid'
maxAge: 86400, // Default: 1 year (in seconds)
},
});Cookie storage is read-only in WebSocket context. For actions that modify session data via WebSocket, use KV storage:
// vite.config.ts
beamPlugin({
session: { storage: "/app/session-storage.ts" },
});// app/session-storage.ts
import { KVSession } from "@benqoder/beam";
export default (sessionId: string, env: { KV: KVNamespace }) =>
new KVSession(sessionId, env.KV);# wrangler.toml
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"
[vars]
SESSION_SECRET = "your-secret-key"Request comes in
↓
Read session ID from signed cookie (beam_sid)
↓
Create session adapter with sessionId + env
↓
ctx.session.get('cart') → adapter.get()
ctx.session.set('cart') → adapter.set()
Two components:
- Session ID: Always stored in a signed cookie (
beam_sid) - Session data: Configurable storage (cookies by default, KV optional)
- Zero boilerplate - Just enable in vite.config.ts and use
ctx.session - Works in actions - Use
ctx.session.get/set/delete - Works in routes - Use
c.get('beam').session.get/set/delete - Cookie storage limit - ~4KB total size
- WebSocket limitation - Cookie storage is read-only in WebSocket (use KV for write operations)
- Signed cookies - Session ID is cryptographically signed to prevent tampering
Beam uses in-band authentication to prevent Cross-Site WebSocket Hijacking (CSWSH) attacks. This is the pattern recommended by capnweb.
WebSocket connections in browsers:
- Always permit cross-site connections (no CORS for WebSocket)
- Automatically send cookies with the upgrade request
- Cannot use custom headers for authentication
This means a malicious site could open a WebSocket to your server, and the browser would send your cookies, authenticating the attacker.
Instead of relying on cookies, Beam requires clients to authenticate explicitly:
- Server generates a short-lived token (embedded in same-origin page)
- WebSocket connects unauthenticated (gets
PublicApi) - Client calls
authenticate(token)to get the full API - Malicious sites can't get the token (CORS blocks page requests)
The auth token is tied to sessions:
// vite.config.ts
beamPlugin({
actions: "/app/actions/*.tsx",
session: true, // Uses env.SESSION_SECRET
});// app/server.ts
import { createApp } from "honox/server";
import { beam } from "virtual:beam";
const app = createApp({
init(app) {
app.use("*", beam.authMiddleware()); // Generates token
beam.init(app);
},
});
export default app;// app/routes/_renderer.tsx
import { jsxRenderer } from "hono/jsx-renderer";
export default jsxRenderer((c, { children }) => {
const token = c.get("beamAuthToken");
return (
<html>
<head>
<meta name="beam-token" content={token} />
<script type="module" src="/app/client.ts"></script>
</head>
<body>{children}</body>
</html>
);
});Or use the helper:
import { beamTokenMeta } from "@benqoder/beam";
import { Raw } from "hono/html";
<head>
<Raw html={beamTokenMeta(c.get("beamAuthToken"))} />
</head>;# .dev.vars (local) or Cloudflare dashboard (production)
SESSION_SECRET=your-secret-key-at-least-32-chars| Step | What Happens |
|---|---|
| 1. Page Load | Server generates 5-minute token, embeds in HTML |
| 2. Client Connects | WebSocket opens, gets PublicApi (unauthenticated) |
| 3. Client Authenticates | Calls publicApi.authenticate(token) |
| 4. Server Validates | Verifies signature, expiration, session match |
| 5. Server Returns | Full BeamServer API (authenticated) |
| Attack | Result |
|---|---|
| Cross-site WebSocket | Can connect, but authenticate() fails (no token) |
| Stolen token | Expires in 5 minutes, tied to session ID |
| Replay attack | Token is single-use per session |
| Token tampering | HMAC-SHA256 signature verification fails |
- Algorithm: HMAC-SHA256
- Lifetime: 5 minutes (configurable)
- Payload:
{ sid: sessionId, uid: userId, exp: timestamp } - Format:
base64(payload).base64(signature)
If you need to generate tokens outside the middleware:
const token = await beam.generateAuthToken(ctx);Call actions directly from JavaScript using window.beam:
// Call action with RPC only (no DOM update)
const response = await window.beam.logout();
// Call action and swap HTML into target
await window.beam.getCartBadge({}, "#cart-count");
// With swap mode
await window.beam.loadMoreProducts(
{ page: 2 },
{ target: "#products", swap: "append" },
);
// Full options object
await window.beam.addToCart(
{ productId: 123 },
{
target: "#cart-badge",
swap: "replace", // 'replace' | 'append' | 'prepend'
},
);window.beam.actionName(data?, options?) → Promise<ActionResponse>
// data: Record<string, unknown> - parameters passed to the action
// options: string | { target?: string, swap?: string }
// - string shorthand: treated as target selector
// - object: full options with target and swap mode
// ActionResponse: { html?: string | string[], script?: string, redirect?: string, target?: string }The API automatically handles:
- HTML swapping: If
options.targetis provided, swaps HTML into the target element - Script execution: If response contains a script, executes it
- Redirects: If response contains a redirect URL, navigates to it
// Server returns script - executed automatically
await window.beam.showNotification({ message: "Hello!" });
// Server returns redirect - navigates automatically
await window.beam.logout(); // ctx.redirect('/login')
// Server returns HTML + script - both handled
await window.beam.addToCart({ id: 42 }, "#cart-badge");window.beam.showToast(message, type?) // Show toast notification
window.beam.closeModal() // Close current modal
window.beam.closeDrawer() // Close current drawer
window.beam.clearCache() // Clear action cache
window.beam.isOnline() // Check connection status
window.beam.getSession() // Get current session ID
window.beam.clearScrollState() // Clear saved scroll stateActions can trigger client-side redirects using ctx.redirect():
// app/actions/auth.tsx
export function logout(ctx: BeamContext<Env>) {
// Clear session, etc.
return ctx.redirect("/login");
}
export function requireAuth(ctx: BeamContext<Env>) {
const user = ctx.session.get("user");
if (!user) {
return ctx.redirect("/login?next=" + encodeURIComponent(ctx.req.url));
}
// Continue with action...
}The redirect is handled automatically on the client side, whether triggered via:
- Button/link click (
beam-action) - Form submission
- Programmatic call (
window.beam.actionName())
For pagination, users expect the back button to return them to the same page. Use beam-replace to update the URL without a full page reload:
<button
beam-action="getProducts"
beam-params='{"page": 2}'
beam-target="#product-list"
beam-replace="?page=2"
>
Page 2
</button>When the user navigates away and clicks back, the browser loads the URL with ?page=2, and your page can read this to show the correct page.
// In your route handler
const page = parseInt(c.req.query("page") || "1");
const products = await getProducts(page);For infinite scroll and load more, Beam automatically preserves:
- Loaded content: All items that were loaded
- Scroll position: Where the user was on the page
This happens automatically when using beam-infinite or beam-load-more. The state is stored in the browser's sessionStorage with a 30-minute TTL.
<div id="product-list" class="product-grid">
{products.map((p) => (
<ProductCard product={p} />
))}
{hasMore && (
<div
class="load-more-sentinel"
beam-infinite
beam-action="loadMoreInfinite"
beam-params='{"page": 2}'
beam-target="#product-list"
/>
)}
</div><div id="product-list" class="product-grid">
{products.map((p) => (
<ProductCard product={p} />
))}
{hasMore && (
<button
class="load-more-btn"
beam-load-more
beam-action="loadMoreProducts"
beam-params='{"page": 2}'
beam-target="#product-list"
>
Load More
</button>
)}
</div>Both patterns work the same way:
- User loads more content (scroll or click)
- User navigates away (clicks a product)
- User clicks the back button
- Content and scroll position are restored
- Fresh server data is applied over cached items (using
beam-item-id)
When restoring from cache, the server still renders fresh Page 1 data. To sync fresh data with cached items, add beam-item-id to your list items:
<!-- Any repeated list item with a stable unique identifier -->
<div class="product-card" beam-item-id={product.id}>...</div>
<div class="comment" beam-item-id={comment.id}>...</div>
<article beam-item-id={post.slug}>...</article>Use beam-item-id for repeated records instead of plain id. It is purpose-built for deduplication, cache restore, and append/prepend freshness.
On back navigation:
- Cache is restored (all loaded items + scroll position)
- Fresh server items are applied over matching cached items
- Server data takes precedence for items in both
This ensures Page 1 items always have fresh data (prices, stock, etc.) while preserving the full scroll state.
When using beam-item-id, Beam automatically handles duplicate items when appending or prepending content:
- Duplicates are replaced: If an item with the same
beam-item-idalready exists, it's updated with fresh data - No double-insertion: The duplicate is removed from incoming HTML after updating
This is useful when:
- New items are inserted while the user is scrolling (causing offset shifts)
- Real-time updates add items that might already exist
- Race conditions between pagination requests
<!-- Items with beam-item-id are automatically deduplicated -->
<div id="feed" class="post-list">
<article beam-item-id="post-123">...</article>
<article beam-item-id="post-124">...</article>
</div>If incoming content contains post-123 again, the existing one is updated with fresh data instead of adding a duplicate.
The action should return the new items plus the next trigger (sentinel or button):
// app/actions/loadMore.tsx
export async function loadMoreProducts(ctx, { page }) {
const items = await getItems(page);
const hasMore = await hasMoreItems(page);
return (
<>
{items.map((item) => (
<ProductCard product={item} />
))}
{hasMore ? (
<button
beam-load-more
beam-action="loadMoreProducts"
beam-params={JSON.stringify({ page: page + 1 })}
beam-target="#product-list"
>
Load More
</button>
) : (
<p>No more items</p>
)}
</>
);
}To manually clear the saved scroll state:
window.beam.clearScrollState();This is useful when you want to force a fresh load, such as after a filter change or form submission.
Beam requires modern browsers with WebSocket support:
- Chrome 16+
- Firefox 11+
- Safari 7+
- Edge 12+
MIT