---
title: >
  Collaborative Realtime Clicker with Server-Side Rendering
---

import { Code } from "@astrojs/starlight/components";
import { githubPath } from "@/lib/github";
import { repo } from "@/config";

This article is a short introduction to some of TrailBase more advanced
features, specifically server-side rendering with a popular JS framework
(React, Vue, Svelte, Solid) and *realtime* subscriptions to data changes.

We'll built a simple *clicker* game, where players can increment a globally
shared counter by click a button and updates are automatically send out to all
participants.

<div class="flex justify-center">
  <div class="w-[360px] shadow-lg	 ">
    ![screenshot](../../../../../examples/collab-clicker-ssr/screenshots/screenshot0.png)
  </div>
</div>

<div class="h-[24px]" />

The conclusion of this tutorial is checked into the repository and can be found
<a href={githubPath("examples/collab-clicker-ssr")}>here</a>
or downloaded by running:

<Code
  code={`
$ git clone ${repo}
$ cd trailbase/examples/collab-clicker-ssr
  `}
  lang="bash"
  frame="none"
/>

import GettingTrailBase from "./_getting_trailbase.mdx";

<GettingTrailBase/>

## Creating an SSR Template Project

We start by setting up a server-side rendered (SSR) application that will
ultimately render in TrailBase's JS/TS runtime.
Because it's JavaScript there's no single way to set up a new project, there's
a million good ones and a *few* more questionable ones 😅.
For this tutorial we'll use `vite`, which has gained notable traction and
provides templates for most popular frameworks. As for frameworks, we'll use
Solid but the same approach applies to React, Vue, Svelte, Preact, ... .

Let's run:

```bash
pnpm create vite@latest <project-name>
```

which will bring up a CLI assistant. Navigate through: `Others > create-vite-extra >
ssr-solid > TypeScript`. If you feel adventures, you can select any of the
other SSR options (React, Svelte, ...) or go with JavaScript instead.

With the project template created, we can simply follow the on-screen
instructions:

```bash
cd <project-name>
pnpm install
pnpm run dev
```

export const createViteBase = "https://github.com/bluwy/create-vite-extra/blob/b03295a95d608d3120b8f620d1df2657eb07811d";

The final command, will start an [express.js](https://expressjs.com/)
development server run by Node.js as defined by `<project-name>/server.js`.
Opening the
<a href={`${createViteBase}/template-ssr-solid-ts/server.js#L41-L63`}>`server.js` implementation</a>,
slightly simplified it looks something like:

```ts
async function handler(req, res) {
    const render = (await import('./dist/server/entry-server.js')).render;

    // 1. Call render function.
    const rendered = await render(req.url);

    // 2. Build HTML template.
    const html = templateHtml
      .replace(`<!--app-head-->`, generateHydrationScript())
      .replace(`<!--app-html-->`, rendered.html);

    res.status(200).set({ 'Content-Type': 'text/html' }).send(html)
}
```

The details are not super important and framework-dependent but in simple terms:

1. The server first loads and executes a render function defined in `dist/server/entry-server.js`.
2. Then slices up the
   <a href={`${createViteBase}/template-ssr-solid-ts/index.html`}>`<project-name>/server.js`</a>
   HTML template and inserts two parts:
   * a serialized "hydration" script in the header,
   * and the output of the above render function as the document body.

With this express.js server setup and the actual rendering delegated to the
respective JS framework, this is really all there is to SSR.
In other words, our TrailBase JS/TS setup will simply need to execute the
render function, then build and serve the HTML template.

Before we start plumbing, it might be worth taking a quick look at the
directory structure: `src/` is where our application code lives, which will be built
into the final artifacts under `dist/` by `pnpm build`.
For example, the render function is defined in `src/entry-server.tsx` and will
be compiled into `/dist/server/entry-server.js`.
Besides the `dist/server` artifacts, there are `dist/client` artifacts
containing the HTML, CSS and JS intended for the user's browser.
We can serve these simply using `trail run --public-dir=<client-artifacts-path>`.


## Implementing the SSR Handler

As discussed above, we ultimately want to execute something akin to:

```ts
import { render } from "dist/server/entry-server.js";
const htmlBody : string = render(/*...*/);
```

to put the body along some hydration script into our
`<project-name>/index.html` template.

### Prep

Let first address a minor obstacle. Running `pnpm build` and looking at the
`dist/server/entry-server.js` artifact we can see that the code is non-hermetic
depending on framework modules.
In order to make our lives easier, let's inline everything by changing
`vite.config.ts` to:

```ts  {3-5}
export default defineConfig({
  plugins: [solid({ ssr: true })],
  ssr: {
    noExternal: true,
  },
})
```

Running `pnpm build` again, we have a standalone renderer without external
dependencies 👍.


### Building a minimal SSR App

We can now start implementing the actual HTTP handler as a WASM component,
which can be run by TrailBase.
It's probably easiest to copy an existing WASM component, e.g.
`/examples/wasm-guest-ts`.[^1]


Stripping out all the example code, and reproducing the above behavior we end
up with something like:

```ts
import { defineConfig } from "trailbase-wasm";
import { HttpHandler, HttpRequest } from "trailbase-wasm/http";
import { readFileSync } from "trailbase-wasm/fs";

// IMPORTANT: update to point to your `<project-name>/dist`
// This assumes that the WASM guest lives in:
//   <project-name>/guests/typescript/
import { render } from "../../../dist/server/entry-server.js";

async function ssr(req: HttpRequest): Promise<string> {
  // 1. Call render function.
  const rendered = render(req.url());

  // 2. Build HTML template.
  const template = new TextDecoder().decode(
    readFileSync("/dist/client/index.html"));

  const html = template
    .replace(`<!--app-head-->`, rendered.head ?? "")
    .replace(`<!--app-html-->`, rendered.html ?? "");

  return html;
}

export default defineConfig({
  httpHandlers: [HttpHandler.get("/", ssr)],
});
```

Voila! Last thing, if you copied the WASM guest template to it's own location,
make sure that the `trailbase-wasm` dependency isn't a workspace dependency but
references the latest upstream
[package](https://www.npmjs.com/package/trailbase-wasm).
Afterwards, you should be able to run `pnpm build` to build a
`dist/component.wasm`.

After placing this component into your `<project-name>/traildepot/wasm` and
starting with:

```sh
cd <project_name>
trail --data-dir=traildepot run --public-dir=dist/client
```

, we have a simple SSR app running with TrailBase 🎉.


## Building the Clicker App

If SSR is what you're after, the tutorial could end here. If you have a few
more minutes, let's build something a little more fun: a clicker app to
collaborative increment a globally shared counter 🤣.

For this we need a few foundational pieces:

1. Create a table with a single record holding the current global counter value.
2. A way to forward the counter state from the initial server-side render to
   the client in a structured manner so we can continue to increment it[^2].
3. An API to increment the counter.
4. Add the actual button and a subscription to get notified whenever someone
   else increments the counter.

The following sections will cover the major beats but skip over some incrementally
evolving glue code to avoid being overly redundant.
Hopefully you can work things out with a little help from the compiler.
If not, you can find the final product
<a href={githubPath("examples/collab-clicker-ssr")}>here</a>
to read along or after finishing the tutorial.

### 1. Setting up the Database

Using TrailBase's migrations, we can simply add a migration file that will
create the schema and the counter entry:

export const migration = `
CREATE TABLE IF NOT EXISTS counter (
  id           INTEGER PRIMARY KEY,
  value        INTEGER NOT NULL DEFAULT 0
) STRICT;

INSERT INTO counter (id, value) VALUES (1, 5);`;

<Code
  code={migration}
  lang="sql"
  title={`traildepot/migrations/U${Math.floor(Date.now()/1000)}__create_table_counter.sql`}
/>

Note, if you're working with a preexisting TrailBase setup, the timestamp in
the file name needs to be larger than your most recent migration.

### 2. Forwarding Server State

We'll simply serialize our state to JSON and inject it as a simple JS script
into the HTML document next to the hydration script.

export const serverEntryPoint = `
export function render(_url: string, count: number) {
  const data = { count } satisfies Clicked;

  return {
    html: renderToString(() => <App initialCount={count} />),
    // Script to set __INITIAL_DATA__ state object on the client from
    // serialized server state.
    data: \`<script>window.__INITIAL_DATA__ = \${JSON.stringify(data)};</script>\`,
  };
}`;

<Code
  code={serverEntryPoint}
  lang="ts"
  title={"src/entry-server.tsx"}
  mark={8}
/>

Afterwards we can update the client's entry-point to read the state object and
inject it into the JS app using the appropriate mechanism for your framework
choice:

export const clientEntryPoint = `
hydrate(
  () => {
    const initialData = window.__INITIAL_DATA__;
    return (
      // Injecting state into the Solid component.
      <App initialCount={initialData?.count ?? 0} />
    );
  },
  document.getElementById('root') as HTMLElement,
)`;

<Code
  code={clientEntryPoint}
  lang="ts"
  title={"src/entry-client.tsx"}
  mark={6}
/>

We also need to update the handler code to query the current count and wire it
into the server-side render function above:

```ts {4-8, 13}
import { query } from "trailbase-wasm/db";

async function ssr(req: HttpRequest): Promise<string> {
  const rows = await query(
      "SELECT value FROM counter WHERE id = 1", []);

  const count = rows.length > 0 ? (rows[0][0] as number) : 0;
  const rendered = render(req.url(), count);

  const html = readTemplate()
    .replace(`<!--app-head-->`, rendered.head ?? "")
    .replace(`<!--app-html-->`, rendered.html ?? "")
    .replace(`<!--app-data-->`, rendered.data ?? "");

  return html;
}
```

### 3. Counter Increment API

We could use record APIs and first read the current `value` and subsequently
write `value+1`, however this would lead to data races potentially loosing
concurrent updates and under-counting.
Instead we want an atomic database update. For this we create a new TS endpoint
in `traildepot/scripts/main.ts`:

```ts
import { query } from "trailbase-wasm/db";

async function clicked(_req: HttpRequest): Promise<string> {
  const rows = await query(
    "UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value",
    [],
  );

  const count = rows.length > 0 ? (rows[0][0] as number) : -1;
  return JSON.stringify({ count });
}

export default defineConfig({
  httpHandlers: [
    HttpHandler.get("/clicked", clicked),
    HttpHandler.get("/", ssr),
  ],
});
```

We'll see in the following paragraph how this endpoint can be called from
`src/App.tsx`.

### 4. UI & Record Subscription

We simply need to expose the `counter` table through a world-readable record
API (using the admin UI or config file):

```proto
# file: traildepot/config.textproto
# schema-file: <repo>/crates/core/proto/config.proto
record_apis: [
  # ...
  {
    name: "counter"
    table_name: "counter"
    acl_world: [READ]
  }
]
```

Afterwards we can build the UI and use the `trailbase` client library to
subscribe as follows:

export const clientCode = `
export function App({ initialCount }: { initialCount?: number }) {
  const [count, setCount] = createSignal(initialCount ?? 0)

  const onClick = () => {
    setCount((count) => count + 1);

    fetch("/clicked").then(async (response) => {
      const clicked = (await response.json()) as Clicked;
      if (clicked.count > count()) {
        setCount(clicked.count);
      }
    });
  };

  onMount(async () => {
    const client = new trailbase.Client(window.location.origin);
    const api = client.records("counter");

    const reader = (await api.subscribe(/*counter_id=*/1)).getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) { break; }

      const update = value as { Update?: { value?: number } };
      const updatedCount = update.Update?.value;
      if (updatedCount && updatedCount > count()) {
        setCount(updatedCount);
      }
    }
  });

  return (
    <div class="flex flex-col gap-4 text-neutral-800">
      <div>
        <button class={buttonStyle} onClick={onClick}>
          <img class="size-[256px] m-2" src={logo} />
        </button>
      </div>

      <button class="px-4 py-2" onClick={onClick}>
        <span class="p-2 bg-neutral-100 rounded font-bold">
          Clicked {count()} times
        </span>
      </button>
    </div>
  )
}

const buttonStyle =
  "p-2 rounded-full hover:bg-accent-200 hover:scale-100 active:scale-90 animate-all";
`;

<Code
  code={clientCode}
  lang="tsx"
  title={"src/App.tsx"}
/>

## Conclusion

At this point you should hopefully have a little collaborative clicker *game* 🎉.
If you got lost along the way - apologies . You can check out the final result
<a href={githubPath("examples/collab-clicker-ssr")}>here</a>.


## What's Next?

Thanks for making it to the end.
Beyond the basic example above, the repository contains a more involved examples, such as:

* A <a href={githubPath("examples/blog")}>Blog</a>
  with both, a Web and Flutter UI, more complex APIs, authorization and custom
  user profiles.

Any questions or suggestions? Reach out on GitHub and help us improve the docs.
Thanks!

----

[^1]:
    For this tutorial we're going to use the TypeScript template, however the checked-in
    example does also contain an implementation using <a
    href={githubPath("examples/collab-clicker-ssr/guests/rust")}>Rust</a> embedding
    QuickJS to execute the render function.

[^2]:
    We could technically parse the value out of the rendered DOM, though we'd
    like to be a bit more robust against future changes to the DOM.
