Loaders

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

Loaders run on the server, for now they run based on their render mode - during build-time for SSG routes, or on each request for SPA or SSR routes.

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.

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 final 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
}

You can combine this with the redirect utility function:

import { redirect } from 'one'
export async function loader({ params: { id } }) {
const user = await db.users.findOne({ id })
if (!user) {
throw redirect('/login')
}
// ... rest of function
}

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.