Loaders

Loaders are useful for one-time loading of data from the server to the client. They can be used in both page routes and layout files in your app directory.

Loaders run on the server based on their render mode - during build-time for SSG routes, or on each request for SPA or SSR routes. Layout loaders and page loaders run in parallel for optimal performance.

Native Platform Behavior

Loaders work similarly on native platforms as they do on web:

  • SSG routes: Data is fetched at compile-time and included in the bundle
  • SSR routes: Data is fetched from the server on each load
  • SPA routes: Data is fetched from the server on each load

This means native apps can use the same loader patterns as web apps, with data fetching behavior determined by the route’s render mode rather than the platform.

Loaders and their imports are removed from client bundles, so you can access private information from within the loader. The data returned from the loader will be passed to the client, and so should be clear of private information.

Accessing Loader Data

There are two hooks available for accessing loader data:

useLoader

The basic hook for accessing loader data:

import { useLoader } from 'one'
export async function loader() {
return {
user: 'tamagui'
}
}
export default function HomePage() {
const data = useLoader(loader)
return (
<p>
{data.user}
</p>
)
}

useLoaderState

For advanced use cases requiring manual refetch or loading states:

import { useLoaderState } from 'one'
export async function loader() {
return {
user: 'tamagui'
}
}
export default function HomePage() {
const { data, refetch, state } = useLoaderState(loader)
return (
<>
<p>{data.user}</p>
<button onClick={refetch} disabled={state === 'loading'}>
Refresh
</button>
</>
)
}

Both hooks are automatically type safe. See useLoader and useLoaderState for detailed documentation.

useMatches

To access loader data from other routes (like a layout accessing page data, or vice versa), use useMatches:

import { useMatches } from 'one'
function Breadcrumbs() {
const matches = useMatches()
// matches = [rootLayout, nestedLayout, currentPage]
// each has: { routeId, pathname, params, loaderData }
return (
<nav>
{matches.map(match => (
<a key={match.routeId} href={match.pathname}>
{match.loaderData?.title}
</a>
))}
</nav>
)
}

See useMatches for detailed documentation.

Layout Loaders

Layouts can export a loader function just like pages:

app/docs/_layout.tsx

import { useLoader, Slot } from 'one'
export async function loader() {
return {
navItems: await fetchDocsNav()
}
}
export default function DocsLayout() {
const { navItems } = useLoader(loader)
return (
<div>
<Sidebar items={navItems} />
<Slot />
</div>
)
}

See Layout Loaders for more details.

Loader arguments

Loaders receive a single argument object:

params

The params key will provide values from any dynamic route segments:

app/user/[id].tsx

export async function loader({ params }) {
// for route /user/jamon params.id is a string "jamon"
const user = await getUser(params.id)
return {
greet: `Hello ${user.name}`
}
}

path

The path key is the fully resolved pathname:

app/user/[id].tsx

export async function loader({ path }) {
// if the route is /user/123 then path is "/user/123"
}

request

Only for ssr type routes. This will pass the same Web standard Request object as API routes.

Accepted Return Types

Most JavaScript values, including primitives and objects, will be converted to JSON.

You may also return a Response:

export async function loader({ params: { id } }) {
const user = await db.users.findOne({ id })
const body = JSON.stringify(user)
return new Response(body, {
headers: {
'Content-Type': 'application/json',
},
})
}

Throwing a Response

A handy pattern for loaders is throwing a response to end it early:

export async function loader({ params: { id } }) {
const user = await db.users.findOne({ id })
if (!user) {
throw Response.error()
}
// ... rest of function
}

Redirects in Loaders

Use the redirect() helper in loaders to protect SSR routes. This is the recommended approach for server-side auth guards — it prevents unauthorized content from ever reaching the client.

app/dashboard+ssr.tsx

import { redirect, useLoader } from 'one'
import type { LoaderProps } from 'one'
export async function loader({ request }: LoaderProps) {
const session = await getSession(request)
if (!session) {
throw redirect('/login')
}
return { user: session.user }
}
export default function Dashboard() {
const { user } = useLoader(loader)
return <Text>Welcome, {user.name}</Text>
}

Both throw redirect() and return redirect() work. throw is often cleaner since it stops execution immediately and makes it obvious the remaining code won’t run.

How Loader Redirects Work

During a direct page load, redirect() returns a standard HTTP 302 response — the browser redirects normally.

During client-side <Link> navigation, loaders run on the server and the client fetches the result as a JavaScript module. Without special handling, the browser’s module loader would silently follow a 302, try to parse the HTML login page as JavaScript, and fail. One handles this differently:

  1. The server detects the redirect response from the loader
  2. Instead of returning a raw 302, it transforms the response into a JS module containing redirect metadata
  3. The client receives the metadata and detects the redirect signal before rendering anything
  4. The client navigates to the redirect target using a REPLACE action — the protected page never appears in the history

This means no sensitive data reaches the client when a redirect occurs. The server only sends back the redirect path, not the page content or loader data.

Redirects at Build Time

For SSG routes, if a loader redirects during the build (e.g., no authenticated request is available), One generates a static redirect file. In production with a runtime server, the server re-evaluates the loader on each request, so authenticated users see the real page while unauthenticated users get redirected.

Use +ssr routes for protected pages so loaders run on every request. SSG routes evaluate loaders once at build time, which won’t have access to per-request auth state.

See the Loader Redirects guide for a complete walkthrough and the redirect helper for the API reference.

Setting Response Headers

Use setResponseHeaders to set HTTP headers on the response from within your loader. This is useful for caching, cookies, and custom headers.

Cache Headers (ISR)

Set cache headers to enable Incremental Static Regeneration - serve SSR pages with static-like performance:

import { setResponseHeaders, useLoader } from 'one'
export async function loader({ params }) {
// Cache at CDN for 1 hour, serve stale while revalidating for up to 1 day
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
})
const post = await fetchPost(params.slug)
return { post }
}

Cookies

Set cookies by appending Set-Cookie headers:

import { setResponseHeaders } from 'one'
export async function loader({ params }) {
await setResponseHeaders((headers) => {
headers.append('Set-Cookie', `visited=${params.slug}; Path=/; HttpOnly`)
})
return { /* ... */ }
}

Read cookies from the request (SSR routes only):

export async function loader({ request }) {
const cookies = request?.headers.get('Cookie') || ''
const session = cookies.match(/session=([^;]+)/)?.[1]
if (!session) {
throw redirect('/login')
}
return { user: await getUser(session) }
}

Custom Headers

Add any custom headers:

await setResponseHeaders((headers) => {
headers.set('X-Custom-Header', 'value')
headers.set('X-Request-Id', crypto.randomUUID())
})

See setResponseHeaders for more details.

Hot Reload for File Dependencies

During development, you can use watchFile to register file dependencies in your loader. When these files change, the loader data will automatically refresh without a full page reload.

This is useful for content-driven sites where loaders read from MDX files, JSON, or other data files:

import { watchFile } from 'one'
import { readFile } from 'fs/promises'
export async function loader({ params: { slug } }) {
const filePath = `./content/${slug}.mdx`
// Register this file for HMR - when it changes, loader will re-run
watchFile(filePath)
const content = await readFile(filePath, 'utf-8')
return { content }
}

watchFile is a no-op in production and on the client, so it’s safe to use unconditionally. The path should be the same path you pass to fs.readFile or similar functions.

Using @vxrn/mdx

The @vxrn/mdx package has built-in HMR support. When used in a One loader, MDX files will automatically trigger hot reload when changed:

import { getMDXBySlug } from '@vxrn/mdx'
export async function loader({ params: { slug } }) {
// HMR is automatic - no watchFile needed
const { frontmatter, code } = await getMDXBySlug('./content', slug)
return { frontmatter, code }
}

For Library Authors

If you’re building a library that reads files and want to support One’s loader HMR, you can use the global hook pattern. This allows your library to work with One’s HMR without depending on the one package:

const WATCH_FILE_KEY = '__oneWatchFile'
function notifyFileRead(filePath: string): void {
const watchFile = globalThis[WATCH_FILE_KEY] as ((path: string) => void) | undefined
if (watchFile) {
watchFile(filePath)
}
}
// Call this whenever you read a file
export function readContent(filePath: string) {
notifyFileRead(filePath)
return fs.readFileSync(filePath, 'utf-8')
}

When One is present, it registers the watchFile implementation on the global object. Your library checks for it and calls it if available - no coupling required. The @vxrn/mdx package exports notifyFileRead if you prefer to import it directly.

Route Validation

One provides two ways to validate routes before navigation: validateParams for schema-based validation and validateRoute for async validation logic.

validateParams

Export a schema to validate route params before navigation. Works with Zod, Valibot, or custom functions:

import { z } from 'zod'
// Validate that id is a UUID
export const validateParams = z.object({
id: z.string().uuid('Invalid ID format')
})
export default function UserPage({ params }) {
// params.id is guaranteed to be a valid UUID
return <UserDetail userId={params.id} />
}

If validation fails, navigation is blocked and an error is shown in the Error Panel (Alt+E).

validateRoute

For async validation (like checking if a resource exists), export a validateRoute function:

export async function validateRoute({ params, search, pathname }) {
// Check if the product exists before allowing navigation
const exists = await checkProductExists(params.slug)
if (!exists) {
return {
valid: false,
error: 'Product not found',
details: { slug: params.slug }
}
}
return { valid: true }
}
export default function ProductPage({ params }) {
// We only get here if the product exists
return <ProductDetail slug={params.slug} />
}

validateParams vs validateRoute

Both can validate routes, but they serve different purposes:

FeaturevalidateParamsvalidateRoute
Runs onClientClient
Use caseSchema validationAsync checks

Use validateParams for simple schema validation (UUIDs, formats, types).

Use validateRoute for async checks on the client (API calls, existence checks).

For server-side auth and redirects, use your loader function and throw a redirect response.

useValidationState

Access validation state from any component:

import { useValidationState } from 'one'
function NavigationStatus() {
const { status, error } = useValidationState()
// status: 'idle' | 'validating' | 'error' | 'valid'
if (status === 'validating') {
return <Spinner />
}
if (status === 'error') {
return <Alert>{error.message}</Alert>
}
return null
}

Edit this page on GitHub.