Layouts & Shared Data
Layouts
Place a layout file in any pages/ directory to wrap all pages in that subtree:
- pages/
- layout.tsxRoot layout (nav, footer)
- index.tsx
- users/
- layout.tsxNested layout for /users/*
- index.tsx
- [id].tsx
// pages/layout.tsx
import { useShared, Link } from '@void/react';
export default function Layout({ children }: { children: React.ReactNode }) {
const { auth } = useShared();
return (
<>
<nav>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
{auth?.user && <span>{auth.user.name}</span>}
</nav>
<main>{children}</main>
</>
);
}<!-- pages/layout.vue -->
<script setup lang="ts">
import { useShared, Link } from '@void/vue';
const { auth } = useShared();
</script>
<template>
<nav>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
<span v-if="auth?.user">{{ auth.user.name }}</span>
</nav>
<main>
<slot />
</main>
</template><!-- pages/layout.svelte -->
<script>
import { useShared, Link } from "@void/svelte";
let { children } = $props();
const { auth } = useShared();
</script>
<nav>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
{#if auth?.user}<span>{auth.user.name}</span>{/if}
</nav>
<main>
{@render children()}
</main>// pages/layout.tsx
import { useShared, Link } from '@void/solid';
import type { JSX } from 'solid-js';
export default function Layout(props: { children: JSX.Element }) {
const shared = useShared<{ auth: { user: { name: string } | null } }>();
return (
<>
<nav>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
{shared.auth?.user && <span>{shared.auth.user.name}</span>}
</nav>
<main>{props.children}</main>
</>
);
}When a page renders, the layout wraps it. The page component is injected as the layout's children in React, Solid, and Svelte, or as a slot in Vue:
Layouts nest automatically and persist across navigations within their subtree, so component state is preserved.
Named Layouts
Named layouts let individual pages opt into a different layout without changing the URL structure. Define them in _layouts/ directories within pages/:
- pages/
- _layouts/
- landing.tsxnamed "landing"
- post.tsxnamed "post"
- layout.tsxdefault root layout
- index.tsx
- blog/
- hello.mdlayout: post
- archive.mdlayout: landing
- pricing.tsxlayout: !landing (exclusive)
Selecting a named layout
Export a layout constant from any page:
export const layout = 'landing';
export default function Page() {
return <div>This page uses the landing layout</div>;
}<script>
export const layout = 'landing';
</script>
<template>
<div>This page uses the landing layout</div>
</template><script context="module">
export const layout = "landing";
</script>
<div>This page uses the landing layout</div>export const layout = 'landing';
export default function Page() {
return <div>This page uses the landing layout</div>;
}For markdown pages, use frontmatter:
---
layout: post
---
# My Blog PostLayout modes
| Value | Behavior |
|---|---|
"landing" | Replace the innermost layout in the chain. Outer ancestor layouts still apply. |
"!landing" | Replace the entire chain. Only the named layout wraps the page. |
false | No layout wrapping at all. Page renders standalone. |
"Innermost" means the deepest layout in the resolved chain. If the chain is [root, docs/layout], the named layout replaces docs/layout. If the chain is only [root], it replaces root.
Resolution order
When a page specifies layout: "post", the scanner walks up the directory tree to find _layouts/post:
pages/guide/intro.md (layout: post)
1. pages/guide/_layouts/post.vue → not found
2. pages/_layouts/post.vue → found ✓Closest ancestor wins, same as default layout chain. If no matching named layout is found in any ancestor, the build fails with a clear error.
Examples
Blog with shared nav but post-specific layout:
- pages/
- _layouts/
- post.tsxsidebar, date, author
- layout.tsxnav + footer
- blog/
- layout.tsxblog sidebar
- hello.mdlayout: post → chain: [root, _layouts/post]
- archive.tsx(no layout) → chain: [root, blog/layout]
layout: post on blog/hello.md replaces the innermost default layout (blog/layout) with _layouts/post. The root layout still wraps.
Fullscreen page with no layout:
export const layout = false;
export default function Landing() {
return (
<div className="hero fullscreen">
<h1>Welcome</h1>
</div>
);
}<script>
export const layout = false;
</script>
<template>
<div class="hero fullscreen"><h1>Welcome</h1></div>
</template>Exclusive layout (skip all ancestors):
---
layout: '!landing'
---
# Standalone Landing PageOnly _layouts/landing wraps this page, and the root layout is skipped entirely.
Shared Data
Middleware can inject data available on every page via c.set("shared", {...}). Augment CloudContextVariables to type the shared data, and useShared() will infer the type automatically:
// middleware/01.auth.ts
import { defineMiddleware } from 'void';
declare module 'void' {
interface CloudContextVariables {
shared: { auth: { user: { name: string } | null } };
}
}
export default defineMiddleware(async (c, next) => {
const user = await getSessionUser(c);
c.set('shared', { auth: { user } });
await next();
});Access it on the client with useShared(). The return type is inferred from your augmentation:
import { useShared } from '@void/react';
export default function Page() {
const { auth } = useShared(); // { auth: { user: { name: string } | null } }
return <p>Hello, {auth?.user?.name}</p>;
}<script setup lang="ts">
import { useShared } from '@void/vue';
const { auth } = useShared(); // { auth: { user: { name: string } | null } }
</script><script>
import { useShared } from "@void/svelte";
const { auth } = useShared(); // { auth: { user: { name: string } | null } }
</script>import { useShared } from '@void/solid';
export default function Page() {
const shared = useShared(); // { auth: { user: { name: string } | null } }
return <p>Hello, {shared.auth?.user?.name}</p>;
}Shared data is separate from page props. Props come from the loader, while useShared() returns global data from middleware. See Type Safety for more on augmenting CloudContextVariables.