> 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](/docs/reference/0.13.0/recipes/dashboard/files/src/app.tsx), [store](/docs/reference/0.13.0/recipes/dashboard/files/src/store.ts), [authentication](/docs/reference/0.13.0/recipes/dashboard/files/src/auth.ts), and [acceptance tests](/docs/reference/0.13.0/recipes/dashboard/files/test/app.test.cjs). 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](/docs/reference/0.13.0/recipes/dashboard.md). 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](/docs/reference/0.13.0/room-authorization.md) and [production boundaries](/docs/reference/0.13.0/production-contract.md).

## 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](/docs/reference/0.13.0/recipes/dashboard.md) 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>;
    }
}
```
