I Built Server-Side Rendering for Aurelia 2

Aurelia 2 JavaScript

Aurelia 2 has had server-side rendering sitting in that slightly annoying place where the core has the right foundations, but app developers still don’t have a tidy package they can install and use.

If you’re building an internal dashboard, you can get away with a pure client-rendered app. Nobody is searching Google for your settings page. But if you’re building a public website, a landing page, a docs site, a marketplace, a product catalogue, or anything where the first HTML response needs to mean something, client-only rendering starts to feel like turning up to a job interview in board shorts.

Google’s JavaScript SEO docs say Google Search can run JavaScript, but there are differences and limitations to account for. Handing search engines a blank shell and asking them to wait for your client bundle, router, data fetching, and rendering cycle before they see the page is making life harder than it needs to be. The same thing applies to users. If the server can send useful HTML up front, the page feels faster because there is something useful on screen before the app wakes up.

I’ve been working on this because I needed it for TryInk. The site is an Aurelia 2 app and it had the classic SPA problem: the app looked fine after JavaScript loaded, but the HTML itself wasn’t carrying enough of the page. That hurts SEO, link previews, first paint, and the general feeling that a site is there when you hit it.

So I built an SSR package for Aurelia 2.

The package is called aurelia2-ssr, and the source lives in my aurelia2-plugins monorepo. As I write this, the published version is 0.0.3, so treat it as early, useful, and still allowed to move around a bit. I want people to use it, break it, complain about it, and help shape it into something Aurelia developers can rely on.

This isn’t an official Aurelia package. That’s worth saying clearly. Aurelia 2 itself has been getting the SSR, SSG, AOT, and hydration groundwork wired into the core. The 2026 roadmap post called that out, and the Aurelia 2 release candidate post mentioned the new Aurelia.hydrate() API. What was missing was the boring glue code that turns those primitives into something an app can use today.

Boring glue code is where most SSR implementations either become pleasant or make you want to push your laptop into the sea.

What the package does

At the centre of the package is a fairly simple idea: render an Aurelia 2 app into a request-scoped DOM, take the HTML from the app host, assemble a full document around it, then let Aurelia take over in the browser.

The server rendering side looks roughly like this:

import { RouterConfiguration } from '@aurelia/router';
import { renderAureliaToString, createSsrRouterRegistrations } from 'aurelia2-ssr';
import { MyApp } from './my-app';

export async function render(url: string, window: Window) {
  const routerRegistrations = await createSsrRouterRegistrations({ path: url });

  return renderAureliaToString({
    window,
    component: MyApp,
    registrations: [
      RouterConfiguration.customize({ useUrlFragmentHash: false }),
      ...routerRegistrations,
    ],
    settle: 50,
  });
}

That function starts Aurelia against the server DOM, lets the app settle, serialises the host element, then stops Aurelia and restores any globals it installed for the render.

Restoring globals matters because a lot of browser-first app code touches window, document, navigator, localStorage, requestAnimationFrame, matchMedia, ResizeObserver, or IntersectionObserver. On the server, those values either don’t exist or they belong to the wrong request if you handle them badly.

aurelia2-ssr has helpers for installing DOM globals from a per-render window, then restoring the previous state afterwards. It also provides memory-backed storage shims, so code that reads localStorage during rendering doesn’t immediately explode. I still think app code should avoid reading browser state at import time, but real apps aren’t tidy demo repos and SSR has to deal with that.

Once the app render exists, buildSsrDocument(...) takes over:

import { buildSsrDocument, createPrebootScript } from 'aurelia2-ssr';

const documentResult = buildSsrDocument({
  template,
  site: ssrSite,
  route,
  render: await render(route.path, window),
  manifest: viteManifest,
  prebootScript: createPrebootScript(ssrSite.preboot),
});

return documentResult.html;

This is where the full HTML page gets built. The package applies SEO tags, replaces the app host, injects resource hints, adds styles and scripts, includes the preboot script, writes structured data, and adds an SSR context block for the client.

If you’re using Vite, you can pass the Vite manifest and tell each route which module IDs matter. The package will collect the entry, imports, and CSS, then inject modulepreload and stylesheet links for that route. That includes CSS modules emitted by the manifest, which is one of those small details that sounds boring until the first route ships without its styles.

SEO lives in the route config

One thing I didn’t want was an SSR package that renders app HTML and leaves SEO as an exercise for the reader.

Search metadata should be explicit, sit next to the route, and be easy to test.

The route config looks like this:

import type { SsrSiteConfig } from 'aurelia2-ssr';

export const ssrSite: SsrSiteConfig = {
  origin: 'https://example.com',
  siteName: 'Example',
  language: 'en-AU',
  defaultOgImage: '/og.png',
  rendering: {
    hostTagName: 'my-app',
    takeoverMode: 'remount',
    settleMs: 50,
    timeoutMs: 5000,
  },
  diagnostics: {
    failOnErrors: true,
    budgets: {
      renderMs: 1200,
      htmlBytes: 180000,
      titleMaxLength: 65,
      descriptionMinLength: 50,
      descriptionMaxLength: 170,
    },
  },
  routes: [
    {
      path: '/',
      seo: {
        title: 'Example home',
        description: 'A useful page description for search engines and social previews.',
        canonicalPath: '/',
        robots: 'index,follow',
        sitemap: {
          include: true,
          priority: 1,
          changefreq: 'weekly',
        },
      },
      priority: {
        level: 'critical',
        moduleIds: ['src/home.ts'],
        images: [{ href: '/hero.webp', fetchPriority: 'high' }],
      },
    },
  ],
};

For each route, you can define title, description, canonical URL, robots policy, Open Graph tags, Twitter card tags, JSON-LD, extra meta tags, extra links, and sitemap data.

The package can also generate sitemap.xml, robots.txt, and an SSR report. The report is useful because SEO bugs are often boring enough to miss during normal testing. Missing descriptions, duplicated app hosts, pages without an h1, huge HTML output, slow renders, or canonicals pointing to the wrong place should be build-time problems, not something you notice two months later when Google Search Console is sulking.

The client takeover problem

The first ugly bug I hit in TryInk was the homepage rendering twice.

The server sent a prerendered <my-app>. Then the browser loaded the client bundle and Aurelia mounted another app into a host that already had a full server-rendered tree inside it. The page didn’t need a second homepage, but apparently nobody told the browser.

The package handles this with a client takeover helper:

import Aurelia from 'aurelia';
import { prepareSsrHostForTakeover, finishSsrTakeover } from 'aurelia2-ssr';
import { MyApp } from './my-app';

const host = prepareSsrHostForTakeover({
  selector: 'my-app',
  mode: 'remount',
});

await Aurelia
  .app({ host: host as HTMLElement, component: MyApp })
  .start();

finishSsrTakeover();

In remount mode, if the document is marked as prerendered, the helper clears the server-rendered app host before Aurelia starts. That gives you valid HTML for crawlers and first paint, then a clean client app once JavaScript has loaded.

There is also a hydrate path. The package re-exports Aurelia’s SSR types and helpers from @aurelia/runtime-html, including ISSRContext, ISSRScope, hydrateSSRDefinition(...), and friends. It also includes hydrateAureliaSsr(...), which calls Aurelia.hydrate(...) when you have a matching SSR scope manifest and AOT-ready definitions.

Remount takeover is the practical option for most apps today. Hydration is the stricter option for apps that can produce and preserve the full manifest data. If the HTML, marker comments, compiled definition, and manifest disagree, hydration should fail loudly. Silent almost-working hydration is worse than no hydration because you end up debugging a page that looks alive but is wired to the wrong nodes.

For routed apps, the package uses Aurelia’s ServerLocationManager from @aurelia/router, so the router can resolve the current URL without pretending the server has browser history and popstate events.

Capturing input before Aurelia wakes up

SSR has another annoying edge: the user can interact with the page before the client app has started.

That sounds like a nice problem to have until somebody types an email address into a prerendered form, the client bundle loads, Aurelia remounts, and their input disappears. Excellent. Very modern. Much UX.

The package includes a small preboot script for this. It can capture input values, checkbox and radio state, selection ranges, focus, clicks, submits, and optional keydown events before Aurelia takes over. After finishSsrTakeover(), it replays the values into the client-rendered controls.

For important fields, you can add a stable selector:

<input data-ssr-key="signup-email" value.bind="email">

The default selector order is data-ssr-key, id, name, and aria-label, with a DOM path fallback. I prefer data-ssr-key for forms that matter because CSS selectors based on structure are one refactor away from disappointment.

Shadow DOM and styles

Aurelia 2 has good Shadow DOM support, so SSR can’t pretend Shadow DOM doesn’t exist.

For open shadow roots, aurelia2-ssr serialises them as Declarative Shadow DOM:

<template shadowrootmode="open" shadowrootserializable>
  <style>:host{display:block}</style>
  <span>Shadow content</span>
</template>

It can also include readable adoptedStyleSheets as a <style data-aurelia-ssr-adopted> block. If a stylesheet’s rules can’t be read, usually because of browser restrictions around origin or access, the serialiser skips it and can send a warning through the configured callback.

Closed shadow roots are different. Normal DOM APIs can’t inspect them, so the package can’t magically serialise private internals. If a component uses closed Shadow DOM and needs SSR output, the component should expose suitable light DOM or provide explicit server-rendered markup. That isn’t an Aurelia limitation as much as a web platform reality with a name badge.

For non-shadow styles, the package handles global styles, inline critical CSS, linked stylesheets, and Vite manifest CSS. Route-specific assets can sit on the route config, while site-wide assets can sit on the site config. Third-party scripts can be placed with strategies like head-start, head-end, before-preboot, before-client, after-client, and body-end. Inline scripts and styles can receive a CSP nonce too.

How to try it

Install the package and a server DOM implementation:

npm install aurelia2-ssr jsdom

If your app uses the Aurelia router, make sure @aurelia/router is installed as well. Most Aurelia 2 apps will already have aurelia and the core Aurelia packages installed. If your package manager is strict about peer dependencies, install the peer packages it asks for.

Then create three small pieces:

  1. A site config that defines your routes, SEO metadata, asset rules, rendering options, and diagnostics.
  2. A server entry that creates a DOM, registers any server-only Aurelia services, renders your root component, and assembles the final document.
  3. A client entry that calls prepareSsrHostForTakeover(...), starts Aurelia, and calls finishSsrTakeover().

For a prerender setup, you can loop over ssrSite.routes, render each route, and write the output into dist. For a server setup, the same pieces can run per request. The package doesn’t force a server framework on you. Use Fastify, Hono, Express, Firebase Functions, Cloudflare where the DOM support works, or a plain build script if you’re generating static HTML.

In TryInk, the build now prerenders the public routes, writes sitemap.xml and robots.txt, and emits an SSR report. The mobile hamburger issue was a takeover issue, and the duplicated homepage was exactly the remount problem. Both are the sort of bug you want the package to solve once so every app doesn’t rediscover it in production while holding a coffee and muttering at DevTools.

What still needs work

The package is useful now, but I don’t want to pretend every problem is solved.

The big future piece is tighter manifest recording. Aurelia core has the hydration pieces, including Aurelia.hydrate(...) and SSR scope types, but the public package story around recording a complete manifest is still forming. So aurelia2-ssr supports remount takeover today and keeps a clean adapter path for full manifest hydration when that pipeline is ready.

Streaming is another area I’d like to look at. The package currently focuses on rendering to a completed HTML document. That is a good fit for prerendering, Firebase Hosting style setups, and many SEO-heavy pages, but streaming would be useful for server-rendered pages with slower data. I don’t want to add it until the shape is right.

Data loading is deliberately app-controlled. There is a settle hook and a settleMs fallback, but the best SSR data story is for your app to know when its route data is ready. Arbitrary sleeps are fine as training wheels and terrible as architecture.

I also want more examples. A minimal Vite app. A router app. A Shadow DOM app. A Firebase Hosting example. A Fastify example. Maybe a TryInk-shaped example with sitemap and diagnostics wired in. The package has tests for document assembly, SEO tags, route validation, preboot replay, Shadow DOM serialisation, browser shims, duplicate host detection, and a small Aurelia render. Examples will make it easier for people to see where each piece goes.

I’m putting this out now because Aurelia 2 deserves a good SSR path. The framework has the shape for it; the missing piece was the package layer that app developers could install, read, copy from, and improve.

If you build Aurelia apps and care about fast first render, SEO, link previews, or pages that don’t begin life as an empty custom element, give aurelia2-ssr a try. The source is in Vheissu/aurelia2-plugins, the package is MIT licensed, and I would love to see it become the shared SSR foundation Aurelia 2 has been missing.