DEV Community

SSR and Hydration in a Small Framework: What Works, What Doesn't, and What's Next (Inside AngeLite, Part 7)

Server-side rendering is where small frameworks usually get stuck: the runtime is full of document.createElement calls, so it can't run on a server without pretending to be a browser. AngeLite has an unusual head start, and an honest incomplete spot. This final post covers both.

What exists today: render in jsdom, no second implementation

tools/ssr.mjs renders your component in Node inside jsdom. That was a deliberate choice: there is no second renderer to keep in sync, so the server output is exactly what the browser would build, with the same URL sanitising and the same escaping.

It exports three functions:

  • renderToString(Comp, options): the markup of the mount element (default #app).
  • renderPage(Comp, { shell, url, state }): a page shell with <!--app--> and <!--state--> markers filled in.
  • serializeState(state): JSON that is safe inside a <script> tag. It escapes <, >, & and the Unicode line separators, which is the classic place for injection bugs in SSR.

Because jsdom provides the globals during a render, renders are queued: two requests at once don't step on each other. Components that load data asynchronously can be awaited with a ready option, and a setup hook runs before mount (for example to call provideRouter(routes, { mode: 'history' }) so the router reads the request URL).

Server and client, concretely

// server (Node)
import { renderPage, serializeState } from '@duvanjamid/angelite/tools/ssr.mjs';
const page = await renderPage(App, { shell, url: req.url, state });
// shell contains <!--app--> and <!--state-->; state is embedded via serializeState

// client
import { hydrate } from '@duvanjamid/angelite/hydrate';
hydrate(App, document.getElementById('app'));

hydrate() is a 15-line module (about 0.1 KB gzip). Read window.__STATE__ to start from the data the server used.

The honest incomplete part

hydrate() replaces the server HTML with the live app in the same task, so there's no flicker, but it does not adopt the existing nodes: the client renders again, and there is no streaming either.

What you gain is visible content before the JavaScript arrives, and SEO. What you don't gain is the work saved by true hydration. The ROADMAP lists "hydration that adopts the server's existing nodes and streaming rendering" as the top Next item, and the comparison table is explicit that this is smaller and less complete than Angular's incremental hydration or what React and Vue offer.

Where the design could go: the blueprint is data

Recall from post 2 that a template compiles once into a blueprint: an HTML fragment plus a list of instructions ("this text node depends on this expression", "this element has this event"). The ahead-of-time (AOT) compiler already serialises that list per node position. That's an unusually good base for the next version of SSR, and it's a direction, not a shipped feature:

  • On the server: evaluate the instructions without a DOM and emit text, removing the jsdom dependency and its serialised queue.
  • On the client: walk the existing DOM with the same instructions (numbering by position already exists) to adopt the server nodes instead of rendering again.

Few frameworks have that starting point, because their template output is code that creates elements rather than data describing them. The project's gap analysis flags it as the thing to exploit "before adding more features".

flowchart LR T[Template] --> C["Compiler (AOT)"] C --> B["Blueprint = HTML + instruction list (data)"] B --> K["Client today: clone + apply instructions"] B -. "today" .-> J["Server today: render in jsdom"] B -. "future" .-> S["Server: evaluate instructions, no DOM"] B -. "future" .-> H["Client: adopt existing nodes"]

Why SSR on a small framework is hard: global state

The gap analysis names the two design limits that SSR runs into:

  • Module-level global state. The pipe registry, the inject() service map, the router, the set of delegated events and the router hook are singletons. You can't render on a server without mixing requests unless you serialise renders (hence the queue), and you can't run two isolated apps on one page. The direction is a createApp() object that owns registries, injector and router.
  • Coupling to the global DOM. The runtime calls document directly, which rules out alternative renderers. The direction is to abstract the few DOM operations (create template, insert, move, remove).

Neither is a bug; both are the price of a core that's about 6 KB. They're also the clearest "next step" list for anyone who wants to contribute.

Tooling around it

Combine it with the Vite plugin (tools/vite.mjs, which reloads on template edits) and angelite-check (file:1: message output that reuses the AOT template checks) for a fast dev loop.

A caution that applies everywhere: SSR multiplies the importance of the security model from post 3. Three rules hold:

  • Never include user input in a template that gets compiled; precompile with the AOT tool.
  • Escape-by-default text and URL filtering apply on the server render too, so server HTML and client DOM agree.
  • Use serializeState rather than hand-rolled JSON.stringify inside a <script> tag.

Series wrap-up

Across seven posts the argument has been the same:

  • Small is a budget, not a goal. It stays small because CI refuses to let it grow silently (post 4), and because optional really means optional (post 5).
  • Fine-grained and boring. Signals, microtask batching and cloned blueprints do the work; there's no virtual DOM and no Zone.js (post 2).
  • Secure by default means tested against attacks. The first test run made the framework look worst of its peers, and that was the point (post 3).
  • Honest comparisons build trust. Publish the method, the noise, and the losses (post 6).
  • Write the debt down. Global state, DOM coupling and re-rendering hydration are in the open (this post).

If you want to try it or contribute, start at duvanjamid/angelite and the documentation. Issues, benchmark corrections and PRs are all welcome, and I'd love to hear from anyone who tries precompiled mode on a real app.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.