Browse documentation

BEYOND QUICK START

Send the first view from the server

Keep the Counter, change the renderer, and run a development server that delivers HTML before the browser module loads.

Keep Counter.ts from the derived state lesson. Now the server will send its first HTML, and the browser will make that existing view interactive.

Put the Counter in a document

Create src/server.ts. Its document template places Counter inside a host and loads a matching browser entry:

<body>
  <main id="app">${Counter}</main>
  <script type="module" src="/src/client.ts"></script>
</body>

Use HtmlRenderTemplate to interpret the document as HTML:

export const markup =
  "<!doctype html>" +
  (await renderToHtmlString(Document).pipe(
    Effect.provide(HtmlRenderTemplate),
    Effect.scoped,
    Effect.runPromise,
  ));

renderToHtmlString collects the first rendered value. Effect.scoped closes the resources used to produce it. The doctype enables standards mode. Keep the rendering comments in the result: they identify DOM ranges during hydration.

Give the browser its matching entry

Create src/client.ts. Target Counter inside #app, so the browser adopts the same subtree the server rendered:

const host = document.getElementById("app");
if (host === null) throw new Error("Missing #app host");

// Attach inside the server's host so this view can reuse its existing DOM.
await render(Counter, host).pipe(
  Fx.drainLayer,
  Layer.provide(DomRenderTemplate.using(document)),
  Layer.launch,
  Effect.runPromise,
);

src/main.ts can remain on disk. The server document loads only /src/client.ts; one entry starts this page’s render lifetime.

Serve the page and its modules

Create dev.ts in the project root. Start Vite as middleware so it can serve the browser’s TypeScript modules:

const vite = await createViteServer({
  appType: "custom",
  server: { middlewareMode: true },
});

For /, the Node request handler loads the server entry and sends its HTML:

const { markup } = await vite.ssrLoadModule("/src/server.ts");
const document = await vite.transformIndexHtml("/", markup);
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(document);

/ receives HTML; /src/client.ts receives JavaScript transformed by Vite. This uses Vite’s middleware SSR API. Copy the complete files before starting the server.

Complete files

src/server.ts
import { HtmlRenderTemplate, html, renderToHtmlString } from "@typed/template";
import { Effect } from "effect";
import { Counter } from "./Counter.js";

// Server and browser render the same Counter module into this host.
const Document = html`<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Typed Counter</title>
  </head>
  <body>
    <main id="app">${Counter}</main>
    <script type="module" src="/src/client.ts"></script>
  </body>
</html>`;

export const markup =
  "<!doctype html>" +
  (await renderToHtmlString(Document).pipe(
    Effect.provide(HtmlRenderTemplate),
    Effect.scoped,
    Effect.runPromise,
  ));
src/client.ts
import { Fx } from "@typed/fx";
import { DomRenderTemplate, render } from "@typed/template";
import { Effect, Layer } from "effect";
import { Counter } from "./Counter.js";

const host = document.getElementById("app");
if (host === null) throw new Error("Missing #app host");

// Attach inside the server's host so this view can reuse its existing DOM.
await render(Counter, host).pipe(
  Fx.drainLayer,
  Layer.provide(DomRenderTemplate.using(document)),
  Layer.launch,
  Effect.runPromise,
);
dev.ts
import { createServer } from "node:http";
import { createServer as createViteServer } from "vite";

const vite = await createViteServer({
  appType: "custom",
  server: { middlewareMode: true },
});
const server = createServer((request, response) => {
  vite.middlewares(request, response, async () => {
    if (request.url !== "/") {
      response.writeHead(404).end("Not found");
      return;
    }
    try {
      const { markup } = await vite.ssrLoadModule("/src/server.ts");
      const document = await vite.transformIndexHtml("/", markup);
      response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(document);
    } catch (error) {
      console.error(error);
      response.writeHead(500).end("The server render failed; see the terminal.");
    }
  });
});
server.listen(5174, "127.0.0.1", () => console.log("Counter: http://127.0.0.1:5174"));
process.once("SIGINT", () => {
  server.close();
  void vite.close();
});

Run the server

Stop the earlier Vite command, then run:

npm install --save-dev tsx @types/node
npx tsx dev.ts

Open http://127.0.0.1:5174. View the page source: Counter, 0, and the buttons should already be in the response. Disable JavaScript and reload to check that the count remains visible. Enable JavaScript and reload again; Increase should now change it to 1.

If / returns 500, check the server terminal. If the HTML appears but the buttons do nothing, check the /src/client.ts request and browser console.

Both sides still initialize zero. Next we’ll restore a value chosen by the server. For a framework that manages the server and asset delivery, see the Astro integration.