---
title: 'Browser Router'
description: 'Add client-side SPA navigation to Ecopages sites with browser-router.'
order: 5
---

import { RuiAlert, RuiAlertDescription, RuiAlertTitle } from '@ecopages/radiant-ui/alert';
import { CodeTabs } from '@/components/code-tabs';

# Client-Side Navigation

The `@ecopages/browser-router` package enables Single Page Application (SPA) navigation behavior in your Ecopages application. It intercepts link clicks, fetches the next page via fetch, and uses [morphdom](https://github.com/patrick-steele-idem/morphdom) to efficiently diff and update only the parts of the DOM that changed.

This preserves element state (like audio players, web component internals, or form values) and enables native View Transitions.

<RuiAlert variant="error" layout="banner" class="unstyled">
	<RuiAlertTitle>Framework Compatibility</RuiAlertTitle>
	<RuiAlertDescription>
		<p>
			This package works with MPA-style rendering (KitaJS, Lit, vanilla JS).{' '}
			<strong>Not compatible with React/Preact</strong>, these frameworks manage their own virtual DOM. For React
			apps, use a framework-specific routing solution.
		</p>
	</RuiAlertDescription>
</RuiAlert>

## Installation

Please run the following command to install the package:

<CodeTabs
	name="browser-router-install"
	tabs={[
		{ id: 'npm', label: 'npm', code: 'npm install @ecopages/browser-router' },
		{ id: 'pnpm', label: 'pnpm', code: 'pnpm add @ecopages/browser-router' },
		{ id: 'bun', label: 'bun', code: 'bun add @ecopages/browser-router' },
	]}
	defaultSelectedKey="npm"
/>

## Setup

To enable client-side routing, initialize the router in your global script (e.g., `src/layouts/base-layout.script.ts`).

<RuiAlert variant="info" layout="banner" class="unstyled">
	<RuiAlertTitle>Consistent Head Ordering Required</RuiAlertTitle>
	<RuiAlertDescription>
		<p>
			To prevent "Flash of Unstyled Content" (FOUC), ensure the router script is injected in a{' '}
			<strong>consistent order</strong> within the <code>&lt;head&gt;</code> across all pages.
		</p>
		<p>
			Inconsistent ordering (e.g., script loaded between stylesheets on one page but after them on another) causes{' '}
			<code>morphdom</code> to detach and re-add stylesheets during navigation. Using a global layout script (like{' '}
			<code>base-layout.script.ts</code>) helps ensure this consistency.
		</p>
	</RuiAlertDescription>
</RuiAlert>

The simplest approach is to use `createRouter`, which creates and starts the router in one call:

```typescript
import { createRouter } from '@ecopages/browser-router/client';

const router = createRouter({
	viewTransitions: true,
});
```

For manual control over when the router starts and stops, use the `EcoRouter` class directly:

```typescript
import { EcoRouter } from '@ecopages/browser-router/client';

const router = new EcoRouter({
	viewTransitions: true,
});

router.start();
```

## Configuration

You can customize the router behavior by passing an options object:

| Option                            | Type                            | Default                                      | Description                                                                                                                                                     |
| :-------------------------------- | :------------------------------ | :------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `linkSelector`                    | `string`                        | `'a[href]'`                                  | Selector for links to intercept.                                                                                                                                |
| `documentElementAttributesToSync` | `string[]`                      | `['lang', 'dir', 'data-eco-document-owner']` | `<html>` attributes to sync from the incoming document. Attributes not listed here are preserved on the live page.                                              |
| `persistAttribute`                | `string`                        | `'data-eco-persist'`                         | Attribute to mark elements that should persist across navigations.                                                                                              |
| `reloadAttribute`                 | `string`                        | `'data-eco-reload'`                          | Attribute (on link or element) to force a full hard reload.                                                                                                     |
| `scrollBehavior`                  | `'top' \| 'preserve' \| 'auto'` | `'top'`                                      | Controls scroll behavior after navigation.                                                                                                                      |
| `smoothScroll`                    | `boolean`                       | `false`                                      | Enables smooth scrolling.                                                                                                                                       |
| `viewTransitions`                 | `boolean`                       | `true`                                       | Enables [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API). Disables UA root crossfade by default. `false` disables. |
| `prefetch`                        | `PrefetchConfig \| false`       | `Object`                                     | Configuration for prefetching behavior.                                                                                                                         |

### View Transitions

The browser router supports the native View Transitions API.

When enabled (default), the router opts the document out of the UA root view-transition group so dark themes do not flash lighter. `startViewTransition` runs only when the current or incoming page has matching `data-view-transition` elements for shared-element morphs.

**Visual check:** dark background → SPA navigate → no lighter flash with defaults.

To create a shared element transition, simply add the `data-view-transition` attribute to matching elements on different pages:

```html
<!-- Page 1 -->
<div data-view-transition="hero-1">...</div>

<!-- Page 2 -->
<div data-view-transition="hero-1">...</div>
```

The router automatically handles the transition names and ensures a clean "morph" animation (hiding the old snapshot to prevent ghosting) by default.

#### Directives

You can control the transition behavior using `data-view-transition-animate`:

| Value       | Description                                                                                                                                    |
| :---------- | :--------------------------------------------------------------------------------------------------------------------------------------------- |
| **`morph`** | (Default) Disables the cross-fade animation, resulting in a clean geometric morph. Ideal for shared element transitions to prevent "ghosting". |
| **`fade`**  | Uses the standard browser cross-fade animation. Useful if you specifically want the old and new elements to fade into each other.              |

```html
<!-- Example: Opt-out of the default morph -->
<div data-view-transition="hero-1" data-view-transition-animate="fade">...</div>
```

#### Duration

You can also control the speed of a specific transition using `data-view-transition-duration`:

```html
<div data-view-transition="hero-1" data-view-transition-duration="500ms">...</div>
```

#### Update History

| `updateHistory` | `boolean` | `true` | Whether to push a new entry to the browser history for each client-side navigation. |
| `smoothScroll` | `boolean` | `true` | Whether to use smooth scrolling when adjusting scroll position after navigation. |

### Example with Options

```typescript
const router = createRouter({
	viewTransitions: true,
	scrollBehavior: 'preserve',
	linkSelector: 'a:not([data-no-route])',
	documentElementAttributesToSync: ['lang', 'dir', 'data-theme'],
});
```

Use `documentElementAttributesToSync` to formalize ownership of root `<html>` attributes. The default keeps navigation metadata in sync while preserving client-managed state such as theme classes or data attributes.

If you need lower-level control without overriding the router pipeline, import the document sync helpers directly:

```typescript
import {
	createRouter,
	DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
	syncDocumentElementAttributes,
} from '@ecopages/browser-router';

const router = createRouter();

document.addEventListener('eco:before-swap', (event) => {
	syncDocumentElementAttributes(document, event.detail.newDocument, [
		...DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
		'data-theme',
	]);
});
```

## Features

### Persistence

Elements marked with `data-eco-persist` are **never recreated** during navigation. morphdom recognizes these elements by their persist ID and skips updating them entirely, preserving their internal state (event listeners, web component state, form values, audio playback, etc.).

Add the `data-eco-persist` attribute with a unique ID:

```tsx
<audio controls src="/music.mp3" data-eco-persist="global-player" />
```

<RuiAlert variant="info" layout="banner" class="unstyled">
	<RuiAlertTitle>How It Works</RuiAlertTitle>
	<RuiAlertDescription>
		<p>
			Unlike traditional DOM swapping which destroys and recreates elements, morphdom diffs the current DOM
			against the new HTML. Persisted elements are matched by their ID and left untouched, keeping their internal
			state intact.
		</p>
	</RuiAlertDescription>
</RuiAlert>

### Script Re-execution

By default, scripts in the `<head>` are not re-executed during navigation if they already exist. However, some scripts (like hydration logic or analytics) need to run on every page load.

To force a script to re-execute on navigation, add `data-eco-rerun="true"`:

```html
<script type="module" data-eco-rerun="true">
	// This runs on initial load AND every navigation
	initAnalytics(window.location.pathname);
</script>
```

Optionally, add `data-eco-script-id="unique-id"` to identify the script. When present, the router removes the previous instance before re-adding the new one, preventing accumulation of duplicate script tags in the head.

```html
<script type="module" data-eco-rerun="true" data-eco-script-id="analytics-tracker">
	// This runs on initial load AND every navigation
	initAnalytics(window.location.pathname);
</script>
```

**How it works:**

- Any script with `data-eco-rerun` is re-executed on every navigation.
- If `data-eco-script-id` is also present, the previous instance is removed first to avoid accumulating duplicate tags.

### Prefetching

The router includes a smart prefetching system to speed up navigation. By default, it uses the `intent` strategy, which prefetches pages when the user hovers over a link or when a link enters the viewport.

#### Configuration

You can configure prefetching in the `createRouter` options:

```typescript
const router = createRouter({
	prefetch: {
		// 'intent' (default) | 'hover' | 'viewport'
		strategy: 'intent',
		// Delay in ms before prefetching on hover (default: 65)
		delay: 65,
		// Attribute to disable prefetching on specific links
		noPrefetchAttribute: 'data-eco-no-prefetch',
		// Whether to respect data-saver mode (default: true)
		respectDataSaver: true,
	},
});
```

To disable prefetching entirely, set `prefetch: false`.

#### Strategies

| Strategy   | Description                                                                                                         |
| :--------- | :------------------------------------------------------------------------------------------------------------------ |
| `intent`   | (Recommended) Prefetches on hover (with delay) and acts as a fallback for `viewport`. Balances speed and bandwidth. |
| `hover`    | Prefetches only when the user hovers over a link.                                                                   |
| `viewport` | Prefetches links as soon as they enter the viewport using IntersectionObserver.                                     |

#### Per-Link Control

You can override prefetching behavior on individual links:

```html
<!-- Disable prefetching for this link -->
<a href="/heavy-page" data-eco-no-prefetch>Heavy Page</a>

<!-- Force eager prefetching (immediately on load) -->
<a href="/next-page" data-eco-prefetch="eager">Next Page</a>

<!-- Use a specific strategy for this link -->
<a href="/about" data-eco-prefetch="hover" data-eco-prefetch-delay="200">About</a>
```

### View Transitions

If `viewTransitions` is enabled (default `true`), the router opts the document out of the UA root group and only uses `startViewTransition` when pages define `data-view-transition` shared elements. Set `false` to disable. Optional presets: `@import 'ecopages/css/view-transitions.css'` and `data-eco-transition` on elements.

#### Using Included Styles

<RuiAlert variant="info" layout="banner" class="unstyled">
	<RuiAlertTitle>View transition CSS ships with the CLI</RuiAlertTitle>
	<RuiAlertDescription>
		<p>
			View transition styles are included in the <code>ecopages</code> npm package, which is the install
			entrypoint for apps.
		</p>
	</RuiAlertDescription>
</RuiAlert>

The `ecopages` npm package includes default view transition animations. Import them in your CSS:

```css
@import 'ecopages/css/view-transitions.css';
```

Then use the `data-eco-transition` attribute on elements to apply specific animations:

| Value        | Description                                               |
| :----------- | :-------------------------------------------------------- |
| `fade`       | Fades the element in and out during transition.           |
| `slide`      | Slides the element horizontally during transition.        |
| `zoom`       | Scales the element in and out during transition.          |
| `slide-up`   | Slides the element vertically upward during transition.   |
| `slide-down` | Slides the element vertically downward during transition. |

```html
<main data-eco-transition="slide">
	<!-- Content slides in -->
</main>
```

## Lifecycle Events

The router emits lifecycle custom events on the `document` object, allowing you to hook into the navigation process.

| Event             | Detail                                    | Description                                                                                                                                              |
| :---------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eco:before-swap` | `{ url, direction, newDocument, reload }` | Fired after fetching new content but **before** updating the DOM. Use `newDocument` to inspect upcoming content. Call `reload()` to force a hard reload. |
| `eco:after-swap`  | `{ url, direction }`                      | Fired **after** the DOM has been updated. Useful for re-initializing scripts or analytics.                                                               |
| `eco:page-load`   | `{ url, direction }`                      | Fired on both initial load and subsequent navigations.                                                                                                   |

### Example: Re-initializing Scripts

```typescript
document.addEventListener('eco:after-swap', () => {
	console.log('Page updated!');
	// Re-run any page-specific logic here
});
```

> **Client-Only Scripts:** Scripts listening to client navigation events (`eco:after-swap`, `eco:page-load`) should be registered as client scripts (e.g., using `scripts: ['./component.script.ts']`) or guarded by `isServer` from `@ecopages/radiant/is-server` when used in Radiant projects where `document` is defined during SSR.

### Example: Navigation-Aware Components

For components that need to react to URL changes (like updating active states), listen for `eco:page-load`:

```typescript
document.addEventListener('eco:page-load', () => {
	// Update active states, re-highlight nav links, etc.
	highlightActiveLink();
});
```
