Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/pages-routing/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export default function Dashboard(props: Props) {

### How Streaming Works

On the initial page load (SSR), React uses React 19 streaming SSR and renders the nearest Suspense fallback for deferred props; the other adapters render their loading state. As each deferred function resolves, the server streams an inline `<script>` tag that delivers the data, so no extra HTTP request is needed. On SPA navigation, deferred data streams via NDJSON over the same response.
On the initial page load (SSR), React uses React 19 streaming SSR and renders the nearest Suspense fallback for deferred props; the other adapters render their loading state. As each deferred function resolves, the server streams an inline `<script>` tag that delivers the data, so no extra HTTP request is needed. Routes with `export const ssr = false` skip server-rendered component HTML but still stream deferred resolution scripts after the client-mounted shell. On SPA navigation, deferred data streams via NDJSON over the same response.

### Deferred Props After Mutations

Expand Down
37 changes: 32 additions & 5 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,48 @@ Each page can have a companion `.server.ts` file that runs exclusively on the se

- 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.

## How Navigation Works

Pages uses an Inertia-style protocol under the hood:

| Request | Response |
| --------------------- | --------------------------------------------------------------------- |
| Initial page load | Full SSR HTML. Client hydrates automatically. |
| Subsequent navigation | JSON with component name + props. Client component swap or re-render. |
| Form submission | Runs action, then returns fresh props or a redirect. |
| Request | Response |
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
| Initial page load | Full SSR HTML. Client hydrates automatically. Routes with `ssr = false` return a client-mounted shell instead. |
| Subsequent navigation | JSON with component name + props. Client component swap or re-render. |
| Form submission | Runs 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.

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. |

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

::: code-group
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Output mode. Controls the default rendering strategy for pages.

When set to `"static"`, all pages are prerendered at build time as static HTML files written to `dist/client/`. Individual pages can opt out with `export const prerender = false`. Dynamic pages (with route params) without a `getPrerenderPaths()` export are implicitly not prerendered.

When omitted or set to `"server"`, pages are server-rendered on request. Individual pages can opt into deploy-time prerendering with `export const prerender = true`.
When omitted or set to `"server"`, pages are server-rendered on request. Individual pages can opt into deploy-time prerendering with `export const prerender = true`, or opt out of server-rendered component HTML with `export const ssr = false`.

```json
{ "output": "static" }
Expand Down
4 changes: 4 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ the hydrated client. Put deferred reads behind Suspense, and use a normal React
error boundary around that Suspense boundary when rejected deferred values need a
custom error state.

Export `ssr = false` from a page's `.server.ts` file to skip server-rendered
component HTML for that route. The loader still runs and the browser mounts the
page with the embedded initial props.

For explicit prop annotations in React pages, import `Deferred` from
`@void/react`:

Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ import { renderReactPage } from "@void/react/pages-server";
${generateComponentManifest(scan, pagesDir)}

const PREAMBLE_SCRIPT = ${JSON.stringify(preambleScript)};
export const renderPreambleScript = PREAMBLE_SCRIPT;

export async function renderPage(pageObj, assetTags) {
return renderReactPage({
Expand All @@ -247,6 +248,7 @@ ${generateComponentManifest(scan, pagesDir)}
preambleScript: PREAMBLE_SCRIPT,
});
}
renderPage.preambleScript = PREAMBLE_SCRIPT;
`;
}

Expand Down
16 changes: 11 additions & 5 deletions packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import React, {
useTransition,
type ComponentType,
} from 'react';
import { hydrateRoot } from 'react-dom/client';
import { createRoot, hydrateRoot } from 'react-dom/client';
import {
createRouterFacade,
createPageNavigationEngine,
Expand Down Expand Up @@ -625,11 +625,17 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
throw new Error('pages: Missing #app element for Void React pages hydration.');
}

const app = React.createElement(App, {
engine,
router,
viewTransitions: config.viewTransitions,
});
startTransition(() => {
hydrateRoot(
appEl,
React.createElement(App, { engine, router, viewTransitions: config.viewTransitions }),
);
if (appEl.getAttribute('data-void-ssr') === 'false') {
createRoot(appEl).render(app);
} else {
hydrateRoot(appEl, app);
}
});
appEl.setAttribute('data-hydrated', 'true');
engine.start();
Expand Down
29 changes: 29 additions & 0 deletions packages/react/test/integration/react-pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,35 @@ function defineClientTests(getBase: () => string) {
}
});

it('client-only page uses the shell on first load and mounts from loader props', async () => {
const htmlRes = await fetch(BASE + '/client-only');
expect(htmlRes.status).toBe(200);
const html = await htmlRes.text();
expect(html).toContain('__VOID_PAGE_DATA__');
expect(html).toContain('data-void-ssr="false"');
expect(html).toContain('<title>Client-only Route | React Pages Demo</title>');
expect(html).toContain('React Pages client-only route');
expect(html).toContain('Client-only Route');
expect(html).not.toContain('Client-only Page');
expect(html).not.toContain('client-only page component rendered on the server');

const page = await newPage();
try {
await page.goto(BASE + '/client-only');
await waitForHydration(page);
await page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('Client-only Page'),
);

expect(await page.textContent('[data-testid="client-only-title"]')).toBe('Client-only Route');
expect(await page.textContent('[data-testid="client-only-marker"]')).toBe(
'loader-props-available',
);
} finally {
await page.context().close();
}
});

it('layout persists across navigation', async () => {
const page = await newPage();

Expand Down
15 changes: 15 additions & 0 deletions packages/react/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,19 @@ describe('pagesPlugin dev config', () => {

expect(renderer).toContain('"test": () => import');
});

it('exposes the React dev preamble for client-only page shells', async () => {
writePage('index.tsx', 'export default function Index() { return <h1>Home</h1>; }\n');

const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks;
getHookHandler(plugin.config)({}, { command: 'serve' });
await getHookHandler(plugin.buildStart).call({});

const load = getHookHandler(plugin.load);
const renderer = await load('\0virtual:void-pages-renderer');

expect(renderer).toContain('__vite_plugin_react_preamble_installed__ = true');
expect(renderer).toContain('export const renderPreambleScript = PREAMBLE_SCRIPT');
expect(renderer).toContain('renderPage.preambleScript = PREAMBLE_SCRIPT');
});
});
4 changes: 4 additions & 0 deletions packages/solid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ import { defineHandler } from 'void';
export const loader = defineHandler(() => ({ title: 'Home' }));
```

Export `ssr = false` from a page's `.server.ts` file to skip server-rendered
component HTML for that route while still running its loader on the first
request.

## Tests

- `test/unit/use-form.test.ts`
Expand Down
9 changes: 7 additions & 2 deletions packages/solid/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ function generateClientEntry(
): string {
return `
import { createSignal, createComponent, batch } from "solid-js";
import { hydrate } from "solid-js/web";
import { hydrate, render } from "solid-js/web";
import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext, setActionRouter } from "@void/solid";
import { createRouterFacade, createVoidRouter, idleNavigationState } from "void/pages-client";
import { parseCacheFor } from "void/pages-prefetch";
Expand Down Expand Up @@ -360,7 +360,12 @@ function App() {
}

const appEl = document.getElementById("app");
hydrate(() => createComponent(App, {}), appEl);
if (!appEl) throw new Error("pages: Missing #app element for Void Solid pages hydration.");
if (appEl.getAttribute("data-void-ssr") === "false") {
render(() => createComponent(App, {}), appEl);
} else {
hydrate(() => createComponent(App, {}), appEl);
}
appEl.setAttribute("data-hydrated", "true");
export { reactiveRouter as router };
start();
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ import { defineHandler } from 'void';
export const loader = defineHandler(() => ({ title: 'Home' }));
```

Export `ssr = false` from a page's `.server.ts` file to skip server-rendered
component HTML for that route while still running its loader on the first
request.

## Tests

- `test/unit/use-form.test.ts`
Expand Down
12 changes: 9 additions & 3 deletions packages/svelte/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ function generateClientEntry(
staticPageData?: StaticPageDataBuildOptions,
): string {
return `
import { hydrate } from "svelte";
import { hydrate, mount } from "svelte";
import { writable } from "svelte/store";
import App from "@void/svelte/App.svelte";
import { setActionRouter } from "@void/svelte";
Expand Down Expand Up @@ -307,7 +307,8 @@ routeParams = initialPageData.params || {};
paramsStore.set(routeParams);

const appEl = document.getElementById("app");
hydrate(App, {
if (!appEl) throw new Error("pages: Missing #app element for Void Svelte pages hydration.");
const appOptions = {
target: appEl,
props: {
page: initialComponent,
Expand All @@ -330,7 +331,12 @@ hydrate(App, {
navigation: navigationStore,
},
},
});
};
if (appEl.getAttribute("data-void-ssr") === "false") {
mount(App, appOptions);
} else {
hydrate(App, appOptions);
}

appEl.setAttribute("data-hydrated", "true");
export { reactiveRouter as router };
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1603,7 +1603,8 @@ async function runFullDeploy(
// with no backend features, deploy as a static site (no worker needed)
if (deployConfig.output === 'static' && pageScan && !isSsr) {
const allPrerenderable = pageScan.pages.every(
(p) => p.prerender && ((p.params.length === 0 && !p.catchAll) || p.hasGetPrerenderPaths),
(p) =>
p.shouldPrerender && ((p.params.length === 0 && !p.catchAll) || p.hasGetPrerenderPaths),
);
const hasNonPageBackend =
hasRoutes || hasMiddleware || hasWebSockets || authEnabled || liveConfig !== undefined;
Expand Down
Loading