No client:only for app pages: a static shell plus an explicit mount

Game and tool pages are a shell plus createApp, not client:only. I own the loading, ready and error states, and the retry button actually works.

Every game and tool page shares one property: it is a [slug] route, so the component changes with the URL. That is exactly where <Component client:only> stops helping.

What client:only does not give you

client:only does one thing: skip SSR and render in the browser. It does not give you a loading state, an error state, or a retry.

So you rebuild three things yourself: while the component chunk downloads the page is blank, and the user cannot tell loading from broken; if mounting throws, the error lands in the console and the page stays empty; and retrying means writing your own state machine anyway.

Under a dynamic route the component is also a variable, while hydration directives are resolved at compile time. Fighting that costs more than skipping it.

The shape of the shell

AppStage.astro emits one container carrying the state:

<div data-app-stage data-state="loading">
  <div data-app-root data-app-kind="games" data-app-slug="snake" data-app-locale="zh"></div>
</div>

The mounter only reads data-* attributes and knows nothing about any specific app:

const loader = loaders[loaderKey(kind, slug)];
const mod = await loader();
createApp(mod.default, { locale }).mount(root);

Three states, owned by CSS

When data-state changes, styles decide which block is visible. The loading copy, the error panel and the retry button ship as static HTML rather than being created by JS, so if the script dies the user still sees that the app failed and a button to try again.

Every app is still its own chunk

The registry uses import.meta.glob with literal relative paths:

const loaders = {
  ...import.meta.glob('../games/*/App.vue'),
  ...import.meta.glob('../tools/*/App.vue'),
};

Vite expands that at build time into a map from file path to dynamic import, so each App.vue becomes its own chunk. Only the app you open gets downloaded. Aliases do not work here: Vite needs a literal relative path.

The price

App pages are not zero-JS like content pages: they carry the mounter and at least one import(). Content pages pull in no Vue runtime at all. The line is drawn at whether the page is an app.

Loading and error are not states the framework owes you; they are two of the states the page already has. Handing them over deletes them from the design.

← Back to all posts

Comments

…