Manual HTML Server Rendering

Server Rendering

elements man html/server Read as markdown

How a page is rendered on the server and picked up by the browser runtime, how to redirect, and how html responses are cached.

Pages render on the server and attach in the browser. Pass data into the template through the route handler. The shape is new html({ ... }).

export default function route(req: Request, res: Response) {
  let users = sql<User>(`select * from users`).all();
  return new html({ users });
}

Usually you'll want to provide all the data needed to render the page from the route. This ensures the page is fully server-rendered. Occasionally you may want to delay data fetching until after the initial page renders; in that case you can call an @rpc function from an oninsert handler. After first render, @rpc and LiveTable are the right tools for browser-initiated data.

Redirect

redirect(url) leaves the current page for another url. Use it in place of assigning window.location directly.

It is one call everywhere: a template handler, a route, middleware, and an @rpc all take the same redirect(url). In the browser it drives window.location; in a route or middleware it writes a 302; in an @rpc the target rides back on the reply and the browser navigates once the call settles.

import { redirect } from "@elements/app";

function attemptSignin(email: string, password: string): string {
  try {
    signinUser(email, password);
    redirect("/");
    return "";
  } catch (err: any) {
    return err.message;
  }
}

Navigation is asynchronous: the browser fetches the next page while this one stays live, so a reactive change made just before you leave repaints the current page before it unloads. This matters most for auth. signinUser above is an @rpc, so the compiler awaits it; session.login runs on the server and the browser applies the session update, flipping session.isLoggedIn(), while the await is still pending, before the redirect(url) line runs.

So keep session-reactive views off pages you sign in or out from. Render the signin page as the form alone, not a page that swaps between the form and a "you're signed in" panel based on session.isLoggedIn(). That panel is the one thing that flashes: it paints where the form was, for the moment before the new page loads.

<!-- Don't: the page body swaps to a signed-in view the instant login lands,
     one frame before redirect() leaves. -->
<main>
  <div e:if={session.isLoggedIn()}>You're signed in. <a href="/">Go home</a></div>
  <form e:else onsubmit={() => { error = attemptSignin(email, password); if (!error) redirect("/"); }}>
    ...
  </form>
</main>

<!-- Do: the page is the form. A signed-in visitor is handled at the route. -->
<main>
  <form onsubmit={() => { error = attemptSignin(email, password); if (!error) redirect("/"); }}>
    ...
  </form>
</main>

Handle an already-signed-in visitor in the route, before the page renders:

export default function route(req: Request, res: Response) {
  if (session.isLoggedIn()) {
    redirect("/");
    return;
  }

  return new html({});
}

A session-reactive navbar is fine. It flips to the signed-in state and the destination shows that same state, so the change carries across the navigation rather than flashing.

redirect() is not control flow. In the browser navigation is asynchronous and the code after it keeps running; on the server the handler keeps running too, and a value returned afterward is discarded rather than written over the redirect. Return early when you want the rest skipped.

redirect() always sends a 302. For any other status, a route can use the http-specific res.redirect(url, 301).

It throws where there is no browser to navigate: a job, a test, module top level, or after a response has already started streaming.

HTML ETag Caching

Every html response carries an etag computed from the page's source, the data passed into the template, and the session state. Unchanged pages return 304 Not Modified, so a browser revisit skips both rendering and transfer when nothing relevant has changed. The page response itself doubles as the cache manifest.