Documentation for Redweb 0.13.0. Install that exact version when following these examples.

Build a private realtime dashboard

Build a page where signed-in users create cards, see their other tabs update, and find the same cards after a server restart. Use the dashboard starter on Node 22.13 or newer; this application uses native SQLite. It is a complete application recipe, not a new database or authentication framework inside Redweb.

Explain it like I'm five

Think of SQLite as a locked notebook and each browser tab as a window onto it. The server checks whose notebook you may open before reading or changing a card. After a successful change, it tells that account's other open windows to read their latest cards. The windows are not the notebook: closing them does not erase saved data.

Follow the design

  1. The setup command below provisions alice after installing dependencies. It prints a generated password once; save it privately. There is no default password. Open http://127.0.0.1:8181/login after development starts.
  2. app.tsx composes the store, session checks, protected page and shutdown. The Cards component below owns presentation and actions, not the database connection.
  3. loading() reads the current account's cards. connected() subscribes that browser connection to private updates; disconnect and disposal release the subscription.
  4. The add/remove actions validate form input before calling the store. The store rechecks the session and owner within each write transaction; a hidden input is not permission to delete someone else's card.
  5. PrivateCards.publish() refreshes only valid subscribers for that account. Assigning the new array to decorated state updates keyed TSX without a manual browser message handler.

See the complete composition, store, authentication, and acceptance tests. The generated recipe supplies all of them together.

Check that it works

Sign in from two tabs, add a card in one, and confirm both show it. A different account must not see it. Restart the process with the same database path and confirm the card remains. Sign out all sessions and verify both tabs lose access. npm test exercises real HTTP, WebSockets, temporary SQLite data, account isolation, restart and expiry; it does not modify your application database. Run your own browser checks for the browsers you support.

Before deployment

Keep DASHBOARD_DATABASE on a writable persistent volume and out of public assets, source control and logs. Follow the recipe's origin, cookie, account-provisioning and backup instructions. Browser refresh is not persistence; successful storage and a retained database are what preserve cards.

On a compiled-only deployment, provision accounts with node dist/admin.js alice using the same database environment and volume, before starting the service. The development npm run add-user script rebuilds first and therefore needs development tooling; the compiled administrator command does not. Never copy a generated password into logs or deployment manifests.

This recipe uses single-process notifications and revocation. Multiple workers do not automatically exchange updates or logout events. It has no password reset, MFA or account recovery; use a dedicated identity integration when those are requirements. Do not replace server-side permission checks with a shared: true page containing private state. See request and room authorization and production boundaries.

Build and run the complete application

sh
npx --yes redweb@0.13.0 init my-dashboard --template dashboard
cd my-dashboard
npm install --save-exact redweb@0.13.0
npm run add-user -- alice
npm test
npm run dev

The complete dashboard recipe contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.

Source walkthrough: src/cards.tsx

tsx
import { action, component, state, type ActionInput, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';
import { z } from 'zod';
import { sessionToken } from './auth';
import { DashboardStore, MAX_CARDS, type Card } from './store';

const addInput = z.object({ title: z.string().trim().min(1).max(80).regex(/^[^\p{Cc}\p{Cf}]+$/u) }).strict();
const removeInput = z.object({ id: z.string().uuid() }).strict();
const tokenOf = (context: LivePageRequestContext) => sessionToken(context.request.get('cookie'));

interface Subscriber { token: string; update(): void; close(): void; }

/** Single-process notifications; SQLite remains the source of truth on every connection. */
export class PrivateCards {
    private readonly accounts = new Map<string, Set<Subscriber>>();
    constructor(readonly store: DashboardStore) {}

    allowed(context: LivePageRequestContext) {
        const session = this.store.session(tokenOf(context));
        return !!session && session.account === context.principal && !context.signal.aborted;
    }

    subscribe(context: LivePageConnectionContext, update: (cards: Card[]) => void): () => void {
        const token = tokenOf(context);
        const session = this.store.session(token);
        if (!session || !this.allowed(context)) throw new Error('Sign in again.');
        let group = this.accounts.get(session.account);
        if (!group) this.accounts.set(session.account, group = new Set());
        let closed = false;
        const subscriber: Subscriber = {
            token, update: () => update(this.store.list(token)),
            close: () => { unsubscribe(); context.socket.close(1008, 'Sign in again.'); },
        };
        const unsubscribe = () => {
            if (closed) return;
            closed = true;
            clearTimeout(expiry);
            context.signal.removeEventListener('abort', unsubscribe);
            group.delete(subscriber);
            if (!group.size && this.accounts.get(session.account) === group) this.accounts.delete(session.account);
        };
        const expiry = setTimeout(subscriber.close, Math.max(1, session.expires - Date.now()));
        expiry.unref();
        group.add(subscriber);
        context.signal.addEventListener('abort', unsubscribe, { once: true });
        try { subscriber.update(); }
        catch (error) { unsubscribe(); throw error; }
        return unsubscribe;
    }

    publish(account: string) {
        for (const subscriber of this.accounts.get(account) ?? []) {
            try {
                if (this.store.session(subscriber.token)?.account === account) subscriber.update();
                else subscriber.close();
            } catch { subscriber.close(); }
        }
    }
}

@component()
export class Cards {
    @state() items: Card[] = [];
    private unsubscribe?: () => void;

    constructor(private readonly cards: PrivateCards) {}
    loading(context: LivePageRequestContext) { this.items = this.cards.store.list(tokenOf(context)); }
    connected(context: LivePageConnectionContext) {
        this.disconnected();
        this.unsubscribe = this.cards.subscribe(context, items => { this.items = items; });
    }
    disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; }
    disposed() { this.disconnected(); }

    @action({ input: addInput })
    add({ title }: ActionInput<typeof addInput>, context: LivePageConnectionContext) {
        this.cards.publish(this.cards.store.add(tokenOf(context), title));
    }

    @action({ input: removeInput })
    remove({ id }: ActionInput<typeof removeInput>, context: LivePageConnectionContext) {
        this.cards.publish(this.cards.store.remove(tokenOf(context), id));
    }

    render() {
        return <section class="cards" aria-label="Your saved cards">
            <form rw-submit="add">
                <label for="card-title">New card</label>
                <input id="card-title" name="title" maxlength="80" required autocomplete="off" />
                <button type="submit" disabled={this.items.length >= MAX_CARDS}>Add card</button>
            </form>
            <p>{this.items.length} / {MAX_CARDS} cards · saved automatically</p>
            <ul class="card-grid">{this.items.map(card => <li key={card.id} data-card-id={card.id}>
                <h2>{card.title}</h2>
                <form rw-submit="remove">
                    <input type="hidden" name="id" value={card.id} />
                    <button type="submit" aria-label={`Delete ${card.title}`}>Delete</button>
                </form>
            </li>)}</ul>
            {!this.items.length && <p>No cards yet. Add your first one above.</p>}
        </section>;
    }
}