Skip to content
brought to you byVoidZero
Private Beta:Void is currently in Private Beta. We do not recommend using it for mission-critical production workloads yet. Please back up your data regularly.

Pages Routing

Pages routing provides the server-rendered, components-as-pages, collocated-data-loading routing system that is seen in many existing JavaScript meta frameworks. However, pages routing in Void is different from most in that it is rendering framework agnostic. It is built on patterns inspired by Inertia.js: The server returns the data, and the component receives it as props. The client-side routing logic is minimal, allowing Void to support any rendering framework Vite can support. Today, React, Vue, Svelte, and Solid have first-party adapters.

Pages mode activates when a pages/ directory exists. It coexists with routes/. Use pages/ for UI pages and routes/ for APIs. Components in pages/ are server-rendered, which means they run in both Cloudflare Workers and browsers.

Pages mode is also entirely optional - you can use any client-side router to build a pure client-side SPA that interacts with the backend API via the typed fetch utility.

Setup

If you start in a scaffoldable empty directory, void init can generate this setup for you: it asks whether you want React, Vue, Svelte, or Solid Pages mode, then lets you pick a D1 starter, a PostgreSQL starter, or Static Pages. The database-backed starters write the adapter-aware vite.config.ts, pages/, and db/ starter files; Static Pages writes just the basic pages/ setup so you can grow into server features later.

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

sh
npm install @void/react
sh
npm install @void/vue
sh
npm install @void/svelte
sh
npm install @void/solid

Add both plugins to your Vite config:

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

export default defineConfig({
  plugins: [voidPlugin(), voidReact()],
});
ts
// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidVue } from '@void/vue/plugin';

export default defineConfig({
  plugins: [voidPlugin(), voidVue()],
});
ts
// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidSvelte } from '@void/svelte/plugin';

export default defineConfig({
  plugins: [voidPlugin(), voidSvelte()],
});
ts
// vite.config.ts
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
import { voidSolid } from '@void/solid/plugin';

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

That is the full setup. You do not need an SSR entry, a client entry, or hydration boilerplate because the adapter generates them for you.

Each adapter plugin includes the framework's Vite plugin (@vitejs/plugin-react, @vitejs/plugin-vue, @sveltejs/vite-plugin-svelte, vite-plugin-solid) so you don't need to install or configure it separately. 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:

  • pages/
    • layout.tsxRoot layout (wraps all pages)
    • index.tsx→ /
    • index.server.tsServer-side loader & action for /
    • about.tsx→ /about
    • users/
      • layout.tsxNested layout for /users/*
      • [id].tsx→ /users/:id
      • [id].server.tsServer-side loader & action for /users/:id
    • blog/
      • hello.md→ /blog/hello (Markdown page)

Each page can have a companion .server.ts file that runs exclusively on the server. It can export:

  • A loader, which runs on GET and returns the data that becomes the page component's props
  • Actions, which handle mutations from forms and programmatic calls. Export a single action or multiple named actions when a page has several mutations

File-based routing rules are the same as server routing: [param] for dynamic segments, [...param] for catch-all, (group)/ for route groups.

How Navigation Works

Pages uses an Inertia-style protocol under the hood:

RequestResponse
Initial page loadFull SSR HTML. Client hydrates automatically.
Subsequent navigationJSON with component name + props. Client component swap or re-render.
Form submissionRuns action, then returns fresh props or a redirect.

This means the first page load is server-rendered for SEO and performance, while later navigations stay fast without full page reloads.

Use the Link component for SPA navigation between pages. It renders an <a> tag that intercepts clicks and navigates without a full page reload:

tsx
import { Link } from "@void/react";

<Link href="/users">Users</Link>
<Link href={`/users/${id}`}>View</Link>
vue
<script setup lang="ts">
import { Link } from '@void/vue';
</script>

<template>
  <Link href="/users">Users</Link>
  <Link :href="`/users/${id}`">View</Link>
</template>
svelte
<script>
  import { Link } from "@void/svelte";
</script>

<Link href="/users">Users</Link>
<Link href={`/users/${id}`}>View</Link>
tsx
import { Link } from "@void/solid";

<Link href="/users">Users</Link>
<Link href={`/users/${id}`}>View</Link>

The Link components also support query data, history replacement, document navigation, and cancellable client-side navigation:

tsx
<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 are GET-only. Passing either prop to a mutation link throws. GET data is merged into the rendered href query string; arrays become repeated keys, null and undefined are omitted, and nested objects throw.

For programmatic navigation, use useRouter():

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

const router = useRouter();
router.visit('/users');
router.refresh(); // re-fetch current page props
router.visit('/logout', { method: 'POST' }); // non-GET navigation

Scroll Restoration

The Void Router automatically saves and restores scroll position during client-side navigation:

  • Back/forward navigation restores the exact scroll position you were at before navigating away.
  • Forward navigation scrolls to the top of the page.
  • Hash links (/docs#api) scroll to the target element. Same-page hash links (#section) skip the server fetch entirely.

This works out of the box with no configuration. If you need to opt out for a specific navigation, pass preserveScroll: true:

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

Link also accepts preserveScroll:

tsx
<Link href="/users" preserveScroll>
  Users
</Link>
vue
<Link href="/users" preserve-scroll>Users</Link>
svelte
<Link href="/users" preserveScroll>Users</Link>
tsx
<Link href="/users" preserveScroll>
  Users
</Link>