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.

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
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>
    </>
  );
}
vue
<!-- 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>
svelte
<!-- 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>
tsx
// 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:

Layout nesting diagram: pages/layout wraps pages/users/layout wraps pages/users/[id] page component

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:

tsx
export const layout = 'landing';

export default function Page() {
  return <div>This page uses the landing layout</div>;
}
vue
<script>
export const layout = 'landing';
</script>

<template>
  <div>This page uses the landing layout</div>
</template>
svelte
<script context="module">
export const layout = "landing";
</script>

<div>This page uses the landing layout</div>
tsx
export const layout = 'landing';

export default function Page() {
  return <div>This page uses the landing layout</div>;
}

For markdown pages, use frontmatter:

md
---
layout: post
---

# My Blog Post

Layout modes

ValueBehavior
"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.
falseNo 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:

tsx
export const layout = false;

export default function Landing() {
  return (
    <div className="hero fullscreen">
      <h1>Welcome</h1>
    </div>
  );
}
vue
<script>
export const layout = false;
</script>

<template>
  <div class="hero fullscreen"><h1>Welcome</h1></div>
</template>

Exclusive layout (skip all ancestors):

md
---
layout: '!landing'
---

# Standalone Landing Page

Only _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:

ts
// 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:

tsx
import { useShared } from '@void/react';

export default function Page() {
  const { auth } = useShared(); // { auth: { user: { name: string } | null } }
  return <p>Hello, {auth?.user?.name}</p>;
}
vue
<script setup lang="ts">
import { useShared } from '@void/vue';
const { auth } = useShared(); // { auth: { user: { name: string } | null } }
</script>
svelte
<script>
  import { useShared } from "@void/svelte";
  const { auth } = useShared(); // { auth: { user: { name: string } | null } }
</script>
tsx
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.