SPA Navigation
This page explains the structure behind SPA navigation: which packages participate, how a page transition differs from a normal load, and how page-scoped behavior is replaced. It is for maintainers. To enable SPA navigation in a site, see the guide.
The big picture
Riebeckite keeps server rendering as the source of truth. SPA navigation is a browser enhancement:
- Normal load: the browser requests a document, the server returns the full page, and HonoX hydrates islands.
- SPA transition: the browser fetches the next document, the runtime replaces the main element, HonoX hydrates the new islands, and the shell stays.
The unit of replacement is the page root, not the whole document.
Diagram source
flowchart LR
Shell["Application shell<br/>header / footer / global UI"]
Page["Page root<br/>main#main-content"]
Next["Next page root"]
Next -->|"replace"| Page
Shell --- Page
Minimal example
export default defineConfig({
site: { title: "My site", baseUrl: "https://example.com" },
spa: true,
});
The only renderer contract is one stable main#main-content per page. The
current app and scaffolds place it in _renderer.tsx around the route content.
Normal navigation and SPA navigation
Normal navigation:
- The browser requests the URL.
- The server renders HTML.
- The browser parses the document.
- The HonoX client hydrates islands.
- App and page initializers mount.
SPA navigation:
- The runtime intercepts a click.
- The runtime fetches HTML for the target URL.
- The runtime unmounts page scope.
- The runtime reconciles head metadata.
- The runtime replaces
main#main-content. - HonoX
createClient()hydrates the new islands. - The runtime mounts page scope on the new root.
- The runtime updates history, scroll, focus, and announcement.
- The
riebeckite:navigationevent fires.
The order matters: page cleanup happens before replacement, and page initialization happens after hydration.
Package responsibilities
| Package | Responsibility |
|---|---|
@riebeckite/core |
Resolves spa config; defines ClientLifecycle, scopes, the cleanup contract, and plugin client-entry metadata. |
@riebeckite/honox |
Generates virtual:riebeckite/client; conditionally imports the SPA runtime; wires page unmount/mount and island hydration. |
@riebeckite/spa |
Owns link interception, fetch, replacement, head reconciliation, history, scroll, focus, announcement, and prefetch. |
| Site application | Provides the stable page root and app shell; registers page behavior through islands or plugin client entries. |
The SPA package does not know about HonoX or plugins. The HonoX integration is
the only place that connects the runtime to createClient() and
ClientLifecycle.
app and page scopes
ClientLifecycle groups initializers into two scopes:
| Scope | Root | Lifetime | Use for |
|---|---|---|---|
app |
document |
First client initialization until disposal | Shell UI that survives navigation |
page |
main#main-content |
One page root | Content-specific behavior |
app initializers mount once. page initializers unmount before the page root
is replaced, then mount again on the new root.
An initializer may return a cleanup function. With
initializerArguments: "context", it also receives { root, config, signal, scope }. scope can register event listeners, timeouts, intervals, and custom
cleanup. Unmount aborts the shared scope signal first, disposes registered
resources, then awaits returned cleanup. Initialization and cleanup errors are
isolated and logged; one failing initializer does not stop the others.
Page behavior should query the supplied root. Any resource attached outside the replaced DOM must use the resource scope or be released by returned cleanup.
Islands and hydration
HonoX createClient() hydrates islands that have not been hydrated yet. The SPA
integration calls it after the new main element is in the document. This is why
islands inside the new page work without a full reload.
createClient() is not a general unmount API. Removing the old main element
removes its DOM and DOM-bound listeners, but HonoX does not tear down arbitrary
island state. Page-scope cleanup is the place for timers, observers, network
requests, and other resources that must be released.
Failure and fallback
The runtime falls back to a normal document navigation when it cannot complete a transition, for example:
- the fetch fails or returns a non-success status
- the fetched response has no
main#main-content - the current page has no
main#main-content - a redirect leaves the current origin
- page cleanup or head replacement throws before the main element is replaced
- a commit hook throws after the main element has been replaced
A newer navigation aborts the older one. An aborted navigation does not fall back; the newer navigation owns the page.
View transitions are optional. If the browser does not support the API, or reduced motion is requested, the commit runs without a transition.
Design constraints
- SPA navigation is progressive enhancement, not a router. The server still renders every URL.
- Only same-origin GET links are handled; forms and programmatic navigation are out of scope.
- The application shell must stay outside
main#main-content. -
Head nodes marked
data-riebeckite-head="page"are replaced. Nodes markedglobalorruntimepersist. Legacy metadata selectors remain page-owned for compatibility. Executable scripts are never replaced or re-executed; scripts and styles without an ownership attribute persist. - Plugin head tags are emitted as page-owned automatically. Other plugin page tags must opt in with the ownership attribute.
-
Plugin page entries must cooperate with the lifecycle: use the supplied root,
register external resources through
scopeor return cleanup, and respect the abort signal. - SPA-off builds do not import the SPA package.