Splitting the frontend and prerendering the public site
How the public site became its own prerendered build, and how a lint rule between it and the application became a package boundary.
Until mid-August, everything the frontend served sat behind a sign-in. There was one
client-side bundle and one HTML template, which carried a blanket noindex because the
application is invite-only. A crawler visiting a page built that way gets an empty shell, and no
page can have a title of its own. Search visibility was one of the reasons to publish a public
site before the product could be sold, so how the site renders couldn't wait for the pages that
would need it.
Over two days I gave the site a build that emits real HTML. I then split the frontend into three packages, so that the site and the application are kept apart by the module resolver and not by convention.
Two origins, and one cookie that crosses them#
The public site is served at avanserv.com and the application at app.avanserv.com. The apex
went to the site because the site is the half meant to be found. The API stays on the
application's origin, which keeps the session cookies host-only and keeps CORS out of every
request the application makes.
The site still has to send a visitor who is already signed in from its home page to the application. Widening the session cookies to the parent domain would do it, and would hand the credential to every subdomain, so that a scripting flaw on a marketing page became a way to ride someone's session. Instead exactly one cookie is domain-scoped. It says only that a session exists, and an inline script reads it before anything paints. It grants nothing, and both of its failure modes are harmless: a stale hint costs one wasted navigation, and a missing one leaves a signed-in visitor on the marketing page.
Three passes to a page#
The site is a second Vite project with its own root, and its build has three passes. The first is a client build and the second a server-side build. The third is a short script that renders each public route and writes the markup and the head into the HTML shell the client build has just emitted. Reusing that emitted shell, and not the source template, is what lets the two passes agree on the hashed asset names without a manifest between them. The build needed no new dependency: Vue already ships its server renderer, and bun runs the script's TypeScript directly.
The head is a pure function of the route's metadata. The prerender and client-side navigation both call it, so a title written into the HTML cannot drift from the one a visitor sees after clicking a link.
The root decides the stylesheet#
The measurement that settled the layout was about CSS. Tailwind v4 decides which files to scan from the Vite project's root, not from the stylesheet's location or the module graph. With the site rooted where the application was, it shipped 123 KB of CSS, with 957 class selectors, for a page that used eight classes. Rooted at its own directory, it shipped 32 KB.
Two cheaper fixes failed on the way. Switching off automatic detection dropped the site's own classes without a word, and excluding the application's directories cut only 12 percent. Most of the weight came from the shared component layer, which is emitted either way.
From a lint rule to a package boundary#
After the first day the separation was still a lint rule. The frontend was one package with one
manifest, so a marketing page importing the application's authentication store would resolve,
build and ship. Nothing stood in the way except no-restricted-imports and whoever read the
diff.
The next day it became three packages in one workspace: the application, the site, and a kit
holding what they share. That kit is the design system, the components that need no session and
the test helpers. The workspace root declares no runtime dependency, and bun is pinned to its
isolated linker. That pairing is what makes the boundary structural. With the default hoisting,
every package's dependencies are lifted into one shared node_modules, which Node's resolution
reaches from any package by walking up, so the site would still have found the application's
state library. With the isolated linker the site's own node_modules holds Vue, the router and
the kit, and resolving anything else from the site fails outright. I proved that in a scratch
workspace before moving close to eight hundred files.
I chose which files went into the kit by walking the import graph, not by reading directory
names, because the directory names were the thing that was wrong. The kit also names itself by
its package name, even in its own files. The usual @/ alias means "this package's source", so
it resolves to a different directory in each of the three programs, and a kit module spelling
@/utils/cn would have asked the application for a file it doesn't have.
I checked the result against the built output, because every failure here is silent. I built both stylesheets before the move and diffed them rule by rule after it. The site's came out byte-identical, down to its content hash. The application's lost seven utilities, every one a bare spelling that no element wore. Seventy-three classes spelled only in kit sources appeared in its CSS, which shows the kit is being scanned at all.
What hid best#
The worst bug of the split passed the production build. The kit exposes its folders through its
package's exports map, and such a map substitutes a path without ever resolving an index file.
An import of a folder of components therefore resolved to a directory. vite build tolerated
that and shipped, while the development server answered with a 500 and the page never rendered.
The only thing that failed was one browser test, and its failure read as an accessibility
violation on a page that had never compiled. A spec now resolves every kit import in the
workspace through the map, and fails on a directory or a missing file.
The rest of that class is anything that resolves a path at run time and not through the module system. Such code keeps working and keeps reporting success, but it points at the wrong tree. The icon generator wrote the site's copy of the tab icons one directory too deep and printed success. The coverage plugin rejects files outside its working directory before it reads its include list, so every component that moved into the kit left end-to-end coverage without a word. And a stale formatter ignore file reformatted the generated API client, after which a drift check reported an API change that had never happened. Searching the repository, comments and documentation included, for the old path literals found about forty such places.
A few days later a similar trap turned up in the client entry. A client that hydrates the prerendered markup and one that throws it away and renders again look identical to every ordinary assertion: same text, same structure, same accessibility results. The only thing that tells them apart is node identity. The spec now holds a reference to an element in the prerendered markup, boots the client, and asserts that the element is still the same object.
What the build now refuses#
The site's gate reads the built files, not the configuration. It checks that every public route produced a file carrying its own heading outside any script, its own title and its own canonical URL. It also refuses an empty mount point, any package from the application, a body that cannot scroll, and the signed-in redirect anywhere but the site's template. A CI job builds both halves and runs that gate. Until then, nothing in CI had ever run a production build of the frontend, so a broken one would have shipped unnoticed.
The site's emitted assets also have byte budgets, which the build enforces. Each raise has to be argued in a ledger beside the number.
The split has costs. There are two artifacts to deploy where there was one, and each environment builds its own, because the canonical URLs are written into the prerendered HTML. The site also needs a real 404, since the single-page fallback the application relies on would answer every typo with the home page and a 200.