Start from the Quick Start project setup. The complete files below include the derived counter, so you can enter this optional path directly. 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. Prefix the result with a doctype to enable standards mode:
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.
Give the browser its matching entry
Create src/client.ts. Target Counter inside #app, so the browser adopts the same subtree the server rendered. Keep the rendering comments in the HTML: they identify DOM ranges during hydration.
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 using the complete file below. Vite’s middleware serves the browser’s TypeScript modules. 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);
This uses Vite’s middleware SSR API. Copy the complete files before starting the server.
Complete files
src/Counter.ts
import { RefSubject } from "@typed/fx";
import { component, html } from "@typed/template";
export const Counter = component(function* () {
// Allocate per mounted view so separate counters keep independent state.
const count = yield* RefSubject.make(0);
// A read view avoids keeping a second mutable value in sync.
const doubled = RefSubject.map(count, (value) => value * 2);
return html`<section class="counter-demo" aria-label="Counter">
<h2>Counter</h2>
<div class="counter-demo__controls">
<button type="button" onclick=${RefSubject.decrement(count)}>Decrease</button>
<output aria-live="polite">${count}</output>
<button type="button" onclick=${RefSubject.increment(count)}>Increase</button>
</div>
<p>Twice the count: ${doubled}</p>
</section>`;
});
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.