Server-rendered pages with TSX
This guide builds HTML pages on the server with TSX: components, lists, a layout, data loaded by
async components, and pages streamed to the browser. It uses
velt/jsx, the default JSX provider; the precise rules are in
the reference. The finished app, with components shared with a
TypeScript client, is examples/apps/ssr-blog.
A first page
JSX works in any .vlt (or .tsx) file. A component is a function from typed props to
JSX.Element, and renderToStringSync turns an element into HTML:
import { renderToStringSync } from "velt/jsx";
function Greeting(props: { name: string }): JSX.Element {
return <h1 class="title">Hello, {props.name}!</h1>;
}
console.log(renderToStringSync(<Greeting name="<Ada>" />));
// <h1 class="title">Hello, <Ada>!</h1>Text and attributes are escaped as they are rendered, so user data can't inject markup. The only
way to write raw HTML is raw(html) from velt/jsx. Tags and attributes are checked:
<h1 clas="title"> is a compile error that suggests class
(Property 'clas' does not exist on type 'JSX.IntrinsicElements["h1"]'. Did you mean 'class'?).
Lists and conditions
A list is an array of elements, usually from map. An optional part is cond ? <x /> : null,
or cond && <x /> as in JavaScript:
import { renderToStringSync } from "velt/jsx";
type Item = { name: string; done: bool };
function TodoList(props: { items: Item[] }): JSX.Element {
const left = props.items.filter((i) => !i.done).length;
return (
<section>
<ul>
{props.items.map((i) => <li class={i.done ? "done" : "open"}>{i.name}</li>)}
</ul>
{left == 0 ? <p>All done.</p> : <p>{left} left</p>}
</section>
);
}
const items: Item[] = [{ name: "Write", done: true }, { name: "Ship", done: false }];
console.log(renderToStringSync(<TodoList items={items} />));
// <section><ul><li class="done">Write</li><li class="open">Ship</li></ul><p>1 left</p></section>A layout
The layout of every page is a function that takes the page's content. Write it as a plain
function rather than a component with children: the content may hold async components (below),
and an element from a component's own props can't be passed on yet
(the reference).
import { renderToStringSync } from "velt/jsx";
function page(title: string, content: JSX.Element): JSX.Element {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
</head>
<body>
<main>{content}</main>
</body>
</html>
);
}
console.log(renderToStringSync(page("Home", <p>Welcome.</p>)));
// <html lang="en"><head><meta charset="utf-8"><title>Home</title></head><body><main><p>Welcome.</p></main></body></html>Loading data with async components
An async component returns Promise<JSX.Element>. It starts when its element is created, so
the loads of a page run concurrently, and rendering waits for them in document order. Render
such a tree with await renderToString(el):
import { renderToString } from "velt/jsx";
async function loadComments(post: string): Promise<string[]> {
await sleep(10); // a database query
return [`first on ${post}`, "second"];
}
async function Comments(props: { post: string }): Promise<JSX.Element> {
const comments = await loadComments(props.post);
return <ul>{comments.map((c) => <li>{c}</li>)}</ul>;
}
const el = (
<article>
<h1>Hello</h1>
<Comments post="hello" />
</article>
);
console.log(await renderToString(el));
// <article><h1>Hello</h1><ul><li>first on hello</li><li>second</li></ul></article>A component that throws, or an async one that rejects, makes rendering throw a RenderError
naming the component.
Streaming pages
renderToStream turns a page into a streamed response body, a BodyStream for
new Response: the markup above each async component goes out before the component is awaited,
so the browser gets the top of the page while the rest loads. Serving it takes an HTTP server,
the http-server package
(adding it). A whole app in src/main.tsx:
import { serve } from "http-server";
import { raw, renderToStream } from "velt/jsx";
async function loadComments(post: string): Promise<string[]> {
await sleep(500); // a database query
return [`first on ${post}`, "second"];
}
async function Comments(props: { post: string }): Promise<JSX.Element> {
const comments = await loadComments(props.post);
return <ul>{comments.map((c) => <li>{c}</li>)}</ul>;
}
function page(title: string, content: JSX.Element): JSX.Element {
return (
<>
{raw("<!DOCTYPE html>")}
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
</head>
<body>
<main>{content}</main>
</body>
</html>
</>
);
}
const server = await serve({ port: 8080 }, (req: Request) => {
const path = new URL(req.url).pathname;
if (!path.startsWith("/posts/")) {
return new Response("not found", { status: 404 });
}
const post = path.slice("/posts/".length);
const el = page(post, <article><h1>{post}</h1><Comments post={post} /></article>);
return new Response(renderToStream(el), {
headers: { "content-type": "text/html; charset=utf-8" },
});
});
console.log(`listening on http://127.0.0.1:${server.port}`);curl -N localhost:8080/posts/hello shows the two chunks arrive half a second apart:
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>hello</title></head><body><main><article><h1>hello</h1>
<ul><li>first on hello</li><li>second</li></ul></article></main></body></html>- The status and headers go out before the first chunk, so decide the status (here the 404) before streaming starts.
rawwrites the doctype unescaped; the fragment (<>…</>) puts it before<html>.- A
src/main.tsxis the program's entry when there is nosrc/main.vlt, and its top-level statements are the program, as in a.vltfile. - If the client goes away, rendering stops; a component that throws while the page streams cuts the response off, so the client sees a failed body rather than a complete one.
Sharing components with a TypeScript client
Components in the common subset of TypeScript and Velt compile with both velt and tsc, so a
browser bundle can use the same files. Keep them in folders listed under tsCompat in
package.vlt and check them with velt check --ts-compat
(the CLI). The provider must exist
on both sides: examples/apps/ssr-blog names its own (jsx: { importSource: "./jsx" }), which
is velt/jsx on the server and a small TypeScript runtime for tsc, and both render the same
HTML.
Testing pages
A page is a value, so a test renders it and compares the HTML:
// tests/greeting.test.vlt
import { renderToStringSync } from "velt/jsx";
import { Greeting } from "../src/greeting";
export function test_escapes_the_name() {
assertEq(renderToStringSync(<Greeting name="<Ada>" />), `<h1 class="title">Hello, <Ada>!</h1>`);
}How fast is it
velt/jsx compiles a tree of HTML elements into constant strings with the dynamic text folded
in, as a hand-written template literal would be, and keeps the markup of nested elements as
pieces that are copied once, when the page is written. bench/jsx compares a TSX page with
hand-written template literals.