---
url: https://void.cloud/guide/pages-routing/overview.md
description: >-
  Turn files in pages/ into routes. A server loader returns data, and the page
  component receives it as props.
---

# Pages Routing

Put UI components in `pages/` to make them routes. A page's server loader returns data, and the component receives it as props. This pattern is inspired by [Inertia.js](https://inertiajs.com/) and works with React, Vue, Svelte, and Solid.

Pages mode starts when a `pages/` directory exists. It can live alongside `routes/`: use pages for UI and routes for APIs. Page components normally run on the server for the first render and in the browser for navigation and interaction.

You can also use your own client-side router and call Void's APIs with [typed fetch](../typed-fetch.md). Pages mode is optional.

## Setup

In an empty project, `void init` can set up Pages for you. Choose Vite+ or plain Vite, then React, Vue, Svelte, or Solid and a database or static starter. The generated app includes the matching Vite config and page files.

If you're adding Pages mode manually, install a framework adapter alongside `void`:

::: code-group

```sh [React]
npm install @void/react
```

```sh [Vue]
npm install @void/vue
```

```sh [Svelte]
npm install @void/svelte
```

```sh [Solid]
npm install @void/solid
```

:::

Add both plugins to your Vite config:

::: code-group

```ts [React]
// vite.config.ts
import { voidReact } from '@void/react/plugin';
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';

export default defineConfig({
  plugins: [voidPlugin(), voidReact()],
});
```

```ts [Vue]
// vite.config.ts
import { voidVue } from '@void/vue/plugin';
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';

export default defineConfig({
  plugins: [voidPlugin(), voidVue()],
});
```

```ts [Svelte]
// vite.config.ts
import { voidSvelte } from '@void/svelte/plugin';
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';

export default defineConfig({
  plugins: [voidPlugin(), voidSvelte()],
});
```

```ts [Solid]
// vite.config.ts
import { voidSolid } from '@void/solid/plugin';
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';

export default defineConfig({
  plugins: [voidPlugin(), voidSolid()],
});
```

:::

Each adapter includes its framework's Vite plugin. Pass framework plugin options via `voidReact({ react: { ... } })`, `voidVue({ vue: { ... } })`, `voidSvelte({ svelte: { ... } })`, or `voidSolid({ solid: { ... } })` if needed.

## Directory Structure

Pages can be flat files such as `about.vue` or directory-based routes such as `about/index.vue`. Both map to the same route:

Use one page file per URL in each environment.

Pair a page with a `.server.ts` file for server code, such as `about.server.ts` for `about.vue`. Companions also support `.js`, `.mts`, `.mjs`, and [JSX](../server-routing.md#jsx-handlers) with `.tsx` or `.jsx`. They can export:

* A [**loader**](./loaders), which runs on `GET` and returns the data that becomes the page component's props
* [**Actions**](./actions-and-forms), which handle mutations from forms and programmatic calls. Export a single `action` or multiple [named actions](./actions-and-forms#named-actions) when a page has several mutations
* `ssr = false` to opt a route out of server-rendered component HTML while keeping server loaders and client-side routing

File-based routing rules are the same as [server routing](../server-routing.md): `[param]` for dynamic segments, `[...param]` for catch-all, `(group)/` for route groups, and a `.dev` or `.prod` suffix for [environment-only pages](#environment-only-pages).

### Environment-only pages

Add a `.dev` or `.prod` suffix before the extension to choose when a page is available:

```
pages/
  about.tsx       →  /about, always
  debug.dev.tsx   →  /debug, development only
  stats.prod.tsx  →  /stats, production only
  home.dev.tsx    →  /home in development
  home.prod.tsx   →  /home in production
```

`.dev` pages run only on the dev server; `.prod` pages run in builds, deployments, and production previews.

Keep the suffix in companion filenames (`home.dev.server.ts`) and place it before `.island` for island pages (`chart.dev.island.tsx`). Layouts are shared across environments.

Production deploys exclude resources used only by `.dev` pages and their companions. Run `void prepare` to generate types for both environments.

## How Navigation Works

The first request receives rendered HTML. Later navigations run the loader and update the page without reloading the document. Form submissions run an action, then refresh page props or redirect.

To opt a specific route out of server-rendered component HTML, export `ssr = false` from its companion `.server.ts` file:

```ts
// pages/dashboard.server.ts
import { defineHandler } from 'void';

export const ssr = false;

export const loader = defineHandler(async () => {
  return { title: 'Dashboard' };
});
```

The loader still runs on the first request, and its props are embedded in the HTML shell. The page component mounts in the browser instead of hydrating server-rendered markup.

Render and prerender flags combine like this:

| Page exports                           | Behavior                                                                  |
| -------------------------------------- | ------------------------------------------------------------------------- |
| `ssr` unset or `true`                  | Server-render component HTML on request.                                  |
| `ssr = false`                          | Return a client-mounted shell on request.                                 |
| `ssr = false` + `prerender = true`     | Prerender a client-mounted shell with embedded loader data.               |
| `ssr = false` + `prerender = false`    | Return the client-mounted shell only on request; never prerender it.      |
| Island page + `ssr = false`            | Invalid. Island pages already use the island renderer.                    |
| `output: "static"` + `prerender` unset | Auto-prerender pages that have known paths, including client-only shells. |

Write `ssr` and `prerender` as `true` or `false` with `export const` in the `.server.ts` file itself. Void reads them without running the file, so it ignores a computed or re-exported value and prints a warning.

### Typed navigation

Use the `Link` component for SPA navigation between pages. Build dynamic URLs with [`routeUrl()`](../type-safety.md#typed-urls-and-navigation) to check the route pattern and its parameters:

::: code-group

```tsx [React]
import { Link } from "@void/react";
import { routeUrl } from 'void/routes';

<Link href="/users">Users</Link>
<Link href={routeUrl('/users/[id]', { params: { id } })}>View</Link>
```

```vue [Vue]
<script setup lang="ts">
import { Link } from '@void/vue';
import { routeUrl } from 'void/routes';
</script>

<template>
  <Link href="/users">Users</Link>
  <Link :href="routeUrl('/users/[id]', { params: { id } })">View</Link>
</template>
```

```svelte [Svelte]
<script>
  import { Link } from "@void/svelte";
  import { routeUrl } from 'void/routes';
</script>

<Link href="/users">Users</Link>
<Link href={routeUrl('/users/[id]', { params: { id } })}>View</Link>
```

```tsx [Solid]
import { Link } from "@void/solid";
import { routeUrl } from 'void/routes';

<Link href="/users">Users</Link>
<Link href={routeUrl('/users/[id]', { params: { id } })}>View</Link>
```

:::

With [`routing.typedRoutes`](../type-safety.md#checked-url-strings), which apps created with `void init` have on, `href` and the router methods below reject plain strings such as `"/user"` that don't match a route.

`Link` also supports query data, history replacement, full-page navigation, and cancellation:

::: code-group

```tsx [React]
import { Link } from '@void/react';

<Link href="/users" data={{ page: 2, tag: ['active', 'new'] }}>
  Filtered users
</Link>

<Link href="/users" replace>
  Users
</Link>

<Link href="/logout" reloadDocument>
  Sign out
</Link>

<Link
  href="/settings"
  onNavigate={(event) => {
    if (!confirm('Leave this page?')) {
      event.preventDefault();
    }
  }}
>
  Settings
</Link>
```

```vue [Vue]
<script setup lang="ts">
import { Link } from '@void/vue';

function onNavigate(event: { preventDefault(): void }) {
  if (!confirm('Leave this page?')) {
    event.preventDefault();
  }
}
</script>

<template>
  <Link href="/users" :data="{ page: 2, tag: ['active', 'new'] }">Filtered users</Link>
  <Link href="/users" replace>Users</Link>
  <Link href="/logout" reload-document>Sign out</Link>
  <Link href="/settings" :on-navigate="onNavigate">Settings</Link>
</template>
```

```svelte [Svelte]
<script lang="ts">
  import { Link } from '@void/svelte';
</script>

<Link href="/users" data={{ page: 2, tag: ['active', 'new'] }}>Filtered users</Link>

<Link href="/users" replace>Users</Link>

<Link href="/logout" reloadDocument>Sign out</Link>

<Link
  href="/settings"
  onNavigate={(event) => {
    if (!confirm('Leave this page?')) {
      event.preventDefault();
    }
  }}
>
  Settings
</Link>
```

```tsx [Solid]
import { Link } from '@void/solid';

<Link href="/users" data={{ page: 2, tag: ['active', 'new'] }}>
  Filtered users
</Link>

<Link href="/users" replace>
  Users
</Link>

<Link href="/logout" reloadDocument>
  Sign out
</Link>

<Link
  href="/settings"
  onNavigate={(event) => {
    if (!confirm('Leave this page?')) {
      event.preventDefault();
    }
  }}
>
  Settings
</Link>
```

:::

`prefetch` and `reloadDocument` work only with GET links. GET `data` becomes query parameters; use scalar values or arrays.

If a deployment changes Void's page-data format, already-open tabs reload on their next page navigation to load the matching client.

For programmatic navigation, use `useRouter()`. Pass a typed destination object to `visit()`, `prefetch()`, or `flush()`:

```ts
import { useRouter } from '@void/vue'; // or "@void/react", "@void/svelte", "@void/solid"
import type { RouteDestination } from 'void/routes';

const router = useRouter();
const profile = {
  pathname: '/users/[id]',
  params: { id: 42 },
  query: { tab: 'activity' },
} satisfies RouteDestination;

await router.prefetch(profile);
await router.visit(profile);
router.flush(profile); // clear its prefetched data
router.refresh(); // re-fetch current page props, keeping the same history entry
router.visit('/logout', { method: 'POST' }); // non-GET navigation
```

You can also pass URL strings or `URL` instances to these router methods. See [Typed URLs and Navigation](../type-safety.md#typed-urls-and-navigation) for query values, fragments, and catch-all routes.

`router.visit()` updates the page in place for same-origin URLs. `http(s)` URLs on another origin get a full page load, and other schemes such as `javascript:` are rejected.

For dynamic route params, use `useParams()` from the same adapter package. Pass the page's URL pattern as the type argument to type the result:

::: code-group

```tsx [React]
// pages/posts/[id].tsx
import { useParams } from '@void/react';

export default function PostPage() {
  const { id } = useParams<'/posts/[id]'>(); // { id: string }
  return <h1>Post {id}</h1>;
}
```

```vue [Vue]
<!-- pages/posts/[id].vue -->
<script setup lang="ts">
import { useParams } from '@void/vue';

const params = useParams<'/posts/[id]'>(); // { id: string }
</script>

<template>
  <h1>Post {{ params.id }}</h1>
</template>
```

```svelte [Svelte]
<!-- pages/posts/[id].svelte -->
<script lang="ts">
  import { useParams } from '@void/svelte';

  const params = useParams<'/posts/[id]'>(); // { id: string }
</script>

<h1>Post {params.id}</h1>
```

```tsx [Solid]
// pages/posts/[id].tsx
import { useParams } from '@void/solid';

export default function PostPage() {
  const params = useParams<'/posts/[id]'>(); // { id: string }
  return <h1>Post {params.id}</h1>;
}
```

:::

See [Page params](../type-safety.md#page-params) for how patterns are checked.

### Active links

`Link` marks the link to the current page with `aria-current="page"` and `data-void-active`. Use them to style navigation:

```css
nav a[data-void-active] {
  font-weight: 600;
}
```

A link is active only on its own path by default. Set `activeMatch="prefix"` to keep it active on nested pages:

::: code-group

```tsx [React]
<nav>
  <Link href="/">Home</Link>
  <Link href="/users" activeMatch="prefix">
    Users
  </Link>
</nav>
```

```vue [Vue]
<nav>
  <Link href="/">Home</Link>
  <Link href="/users" active-match="prefix">Users</Link>
</nav>
```

```svelte [Svelte]
<nav>
  <Link href="/">Home</Link>
  <Link href="/users" activeMatch="prefix">Users</Link>
</nav>
```

```tsx [Solid]
<nav>
  <Link href="/">Home</Link>
  <Link href="/users" activeMatch="prefix">
    Users
  </Link>
</nav>
```

:::

| Current URL     | `href="/users"` | `href="/users" activeMatch="prefix"` |
| --------------- | --------------- | ------------------------------------ |
| `/users`        | both attributes | both attributes                      |
| `/users?page=2` | both attributes | both attributes                      |
| `/users/42`     | none            | `data-void-active`                   |
| `/users-admin`  | none            | none                                 |

`aria-current="page"` marks only the exact page, so select `[aria-current="page"]` to style it differently from its section. A link to `/` is active only on the home page.

A link without a query ignores the current query, as `/users?page=2` shows above. A link with a query is active only when the current URL has each of its params with the same value. The current URL can have other params, in any order, and a GET link's `data` counts as part of its query. Pagination links to other pages are not marked, while a filter tab stays active as you page through it:

| Current URL                  | Link                           | Active          |
| ---------------------------- | ------------------------------ | --------------- |
| `/posts?page=2`              | `href="/posts?page=1"`         | none            |
| `/posts?page=2`              | `href="/posts?page=2"`         | both attributes |
| `/issues?status=open&page=2` | `href="/issues?status=open"`   | both attributes |
| `/issues?status=open`        | `href="/issues?status=closed"` | none            |

On nested pages, `activeMatch="prefix"` compares only the path, so `href="/reports?range=30d"` has `data-void-active` on `/reports/42`.

Paths match the way pages are routed: letter case and trailing slashes count. The hash is ignored. Links to other origins and non-GET links are never active. An `aria-current` or `data-void-active` you pass to `Link` replaces the computed value. Pass them yourself on a link that stands for a default value, such as an "All" tab at `/issues`, or a `?page=1` link when page 1 is `/posts`.

### Pending links

While a page loads, `Link` adds `data-void-pending` to every link to it. Use it to show that a click is being handled:

```css
a[data-void-pending] {
  opacity: 0.6;
  /* Skip visits that finish quickly, such as to a prefetched page */
  transition: opacity 0.15s 0.1s;
}
```

A link is pending only while its exact destination loads. The path, query and hash must all match, so a link to `/users?page=2` isn't pending while `/users?page=3` loads. A non-GET link, which renders a `<button>`, is pending while a request with its method to its `href` runs. `router.refresh()` reloads the current page, so links to it are pending during a refresh.

A `data-void-pending` you pass to `Link` replaces the computed value. For a loading indicator that covers the whole page, use [`useNavigation()`](../../reference/api/pages.md).

### Prefetching

Add `prefetch` to a `Link` to load its page before the user clicks, so the visit doesn't wait for the server:

```tsx
<Link href="/dashboard" prefetch>
  Dashboard
</Link>
```

`prefetch` on its own loads the page on hover, keyboard focus, or touch. A `Link` can also prefetch on click, as soon as it renders, or once it stays in view, and `cacheFor` sets how long a prefetched page is reused. See [Prefetching](../../reference/api/pages.md#prefetching) for every option.

To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the adapter plugin. A `Link` that sets its own `prefetch` keeps it:

```ts
// vite.config.ts
voidReact({ prefetch: { defaultStrategy: 'hover' } });
```

Prefer `'hover'` or `'click'` as the default. With `'mount'` or `'visible'`, every `Link` loads its page once it renders or stays in view, which adds up in long lists.

`Link` doesn't prefetch while the browser reports that data saver is on or the connection is 2G, which only Chromium-based browsers report.

Prefetching runs the destination's loader, so keep loaders free of side effects such as counting views.

## Scroll Restoration

Void manages scroll position for Pages automatically:

* **Back/forward navigation and reloads** restore the saved scroll position.
* **New navigation** scrolls to the top of the page.
* **Actions and forms** keep scroll when updating the current page. Redirects follow normal navigation behavior.
* **Hash links** (`/docs#api`) scroll to the target element. Same-page hash links (`#section`) skip the server fetch entirely.

To keep the current scroll position, pass `preserveScroll: true`:

```ts
router.visit('/users', { preserveScroll: true });
```

`Link` also accepts `preserveScroll`:

::: code-group

```tsx [React]
<Link href="/users" preserveScroll>
  Users
</Link>
```

```vue [Vue]
<Link href="/users" preserve-scroll>Users</Link>
```

```svelte [Svelte]
<Link href="/users" preserveScroll>Users</Link>
```

```tsx [Solid]
<Link href="/users" preserveScroll>
  Users
</Link>
```

:::
