Technical Documentation: onejs/one
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
One
We'll get this filled out properly soon.
See our documentation website for more.
Apps Onestack.Dev Data Docs Native Overview (`apps/onestack.dev/data/docs/native-overview.mdx`)
title: One Native
One Native is the native component and service library included in one.
Use it alongside React Native views and One's file router. It supplies SwiftUI
on iOS, Jetpack Compose on Android, shared components, and native services.
Start with setup, choose your
components, and look up the
platform APIs. Read the
platform notes before sharing a screen across targets.
The Native reference has a page for each component family and service.
Imports and namespaces
import { One, useNativeState, useSizeClass } from 'one'| Namespace | What it contains |
|---|---|
One.iOS |
SwiftUI components and iOS services such as Location, Audio, and Photos |
One.Android |
Compose components, Android colors, and native menus |
One.UI |
Shared components: Image, Map, Icon, TextInput, effects, fonts, and safe areas |
One |
Shared services such as Notifications, Clipboard, Browser, and SecureStore |
Hooks are named imports. For example, useNativeState creates state that bound
native controls can share; useSizeClass reads the current window size class.
Components and services use the One namespace.
Choose a platform explicitly
One.iOS and One.Android expose different native frameworks. Keep the
platform branch outside the component that renders those views. A web route
can render ordinary React DOM content:
import { useState } from 'react'
import { One } from 'one'
export function NotificationsSetting() {
const [enabled, setEnabled] = useState(true)
if (One.platform === 'ios') {
return (
<One.iOS.Toggle
label="Notifications"
isOn={enabled}
onIsOnChange={setEnabled}
/>
)
}
if (One.platform === 'android') {
return (
<One.Android.Switch
label="Notifications"
isOn={enabled}
onIsOnChange={setEnabled}
/>
)
}
return (
<label>
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
/>
Notifications
</label>
)
}You can also put native implementations in .ios.tsx and .android.tsx
files and the web implementation in .tsx. See
platform-specific routing.
The routed Stack and Tabs remain the navigation layer for file routes.One.iOS.NavigationStack and One.iOS.TabView are separate primitives for
explicit SwiftUI composition. Choose the layer that owns the navigation you
are building; see the component guide.
Credit
One native services use Nitro Modules,
created by Marc Rousavy and Margelo. Views are Fabric components, not Nitro
views.
Releasing (`docs/releasing.md`)
Releasing One
Stable releases
Main pushes do not publish packages. Keep unreleased features on one release
branch and assemble them into a single minor release PR. Test branch canaries
and the installed packages in downstream apps. Prepare the minor version in
that PR, then ask the owner to review the complete batch before merging and
cutting the stable minor. Do not release features as patches.
Patch releases contain reviewed bug fixes only. Do not cut a patch from a
source containing unreleased features. Dependency and documentation maintenance
need no library release when published runtime bytes are unchanged.
main requires the checks and tests jobs on every pull request. After the
approved release PR merges and full CI passes for that exact source, publish
with an explicit workflow dispatch:
gh workflow run release.yml --repo onejs/one --ref main -f release=minorA version prepared in the PR above npm latest is published as is, provided its
bump matches the requested release kind. A patch dispatch fails when main
carries a prepared minor. If no newer version was prepared, the workflow starts
from npm latest and creates the requested bump on its release tag. It never
pushes a generated version commit directly to protected main.
Prepare a feature batch version on its branch without publishing:
bun scripts/release.ts --minor --ci --dirty \
--skip-publish --skip-push --skip-tests --skip-native-testsRun bun install, commit the prepared version with the batch, and validate the
complete PR before owner review. release=republish publishes the version main
already carries after exact-source full CI; use it only for an approved prepared
version or to finish that version's interrupted publication.
Canaries
Pushing v2-beta publishes a canary automatically. Canaries publish the
prepared tree to the canary dist-tag and mutate no git at all. They build
the packages without requiring full CI and never move latest.
For an explicit branch canary:
gh workflow run release.yml --repo onejs/one --ref <branch> -f release=canaryManual canaries use a queue per branch, separate from push canaries. A newerv2-beta push cannot replace a queued manual canary.
One V2 canaries use 2.0.0-0.canary.<timestamp>. Pin the printed version
because the shared canary tag moves. One branch canaries need no owner approval. Branch canaries and normal beta
releases use separate queues.
To test an upstream fix downstream without publishing anything at all, prefer:
bun release --into ~/<downstream>Verify a canary by its content: run npm pack @<version> --ignore-scripts,
extract it, check releaseSourceCommit in its manifest, and inspect the changed
source or built output. A local --into build can keep a version while changing
code, so the version string alone is insufficient.
V2 beta branch
Pushing v2-beta publishes a canary. Publish a V2 beta on the beta dist-tag
with a manual workflow dispatch:
gh workflow run release.yml --repo onejs/one --ref v2-beta -f release=betaThe workflow requires successful full CI for the exact current v2-beta SHA,
then publishes versions named 2.0.0-beta.<workflow-run>.<attempt> on the npmbeta dist-tag. It does not push a version commit, create a tag, or create a
GitHub release. Packages marked skipPublish remain excluded.
The publish step verifies itself: after npm publish returns it polls the
registry for every package in the publish set and fails the run if any version
is still missing, so a green Release run means those versions are readable on
npm. The registry publishes version documents asynchronously and can take
several minutes per package, which is why this polls rather than checking once.
A registry check in the first few minutes after a run is not evidence that a
package was skipped.
Inspect a published beta by its content:
npm pack [email protected].<workflow-run>.<attempt>Apps Onestack.Dev Data Docs Cli (`apps/onestack.dev/data/docs/cli.mdx`)
title: CLI
One provides a single CLI for development, building, and serving your apps. Run npx one --help for the full list, or npx one <command> --help for one command.
npx one
Scaffolding and entry point:
npx one [name]With no arguments inside an existing app (a vite.config.ts is present), this runs the dev server. Otherwise it starts the create flow, scaffolding a new app into [name].
one dev
Start the development server. See one dev for details.
one dev [options]
--clean # clear all caches before running
--host # set the hostname to bind to
--port # set the port to bind to
--mode # "development" or "production": run the dev server against the production bundle
--debug-bundle # write the bundle to a temp file and serve it from there for debugging
--debug # pass debug args to Vite
--extra-config # path to an extra Vite config file to merge on top of the project configThe One development server serves both web and native apps on the same port at the same time, and should hot reload both at once. When you see the Server running on message with a host and port, that is the same for both native and web.
one build
Build your app for production. See one build for details.
one build [options]
--platform web|ios|android # what to build (default: web)
--only <pattern> # limit the pages built to a micromatch pattern
--step <name> # run a single internal build step
--skip-env # skip loading .env files during buildone serve
Serve a built web app for production. See one serve for details.
one serve [options]
--host # set the hostname to bind to
--port # set the port to bind to
--compress # enable gzip compression (default: true)
--loadEnv # load .env files before running
--outDir # build output directory (default: dist)
--cluster # enable cluster mode using all CPU cores
--cluster=N # enable cluster mode with N workersYou must run one build before serve.
Programmatic Usage
You can also run your production server programmatically:
import { serve } from 'one/serve'
await serve()Which takes options:
type ServeOptions = {
// you can pass in your own Hono server
app?: Hono
host?: string
port?: number
compress?: boolean
outDir?: string
/**
* Whether to run the Vite logic to load .env files before running the server
* @default false
*/
loadEnv?: boolean
/**
* Enable cluster mode with multiple worker processes.
* Pass true to use all CPU cores, or a number for specific worker count.
*/
cluster?: boolean | number
}one prebuild
Generate the native Xcode and Android projects. Requires native.app in your Vite config with the app name, iOS bundle ID, and Android application ID. See the Build or Run the Native iOS App guide.
one prebuild [options]
--platform ios|android # which native project to generate
--no-install # skip installing native dependenciesApps with expo in their dependencies use Expo prebuild instead; see Coming from Expo.
one run:ios
Build and launch the app on an iOS simulator. Generates the native project with prebuild first when ./ios does not exist yet. Start one dev first so the app can connect to the bundler.
one run:ios [options]
--simulator <name> # explicitly set the simulator to use, e.g. "iPhone 16"
--udid <udid> # explicitly set the simulator to use by UDIDone run:android
Build and launch the app on Android. Generates the native project with prebuild first when ./android does not exist yet. Start one dev first so the app can connect to the bundler.
one run:androidone clean
Remove build folders and caches for the current project.
one cleanone patch
Apply package patches configured via the patches option in your Vite config. Patches are automatically applied during one dev and one build, but you can run this command to apply them manually.
one patch [options]
--force # re-apply all patches, even if already appliedone generate-routes
Generate route type definitions (app/routes.d.ts). See Typed Routes for details.
one generate-routes [options]
--appDir <path> # path to app directory (default: "app")
--typed type|runtime # auto-generate route helpers alongside the typesone typegen
Alias for one generate-routes. Takes the same options.
one typegen [--appDir <path>] [--typed type|runtime]one metro-eject
Write babel.config.cjs and metro.config.cjs into your project so you can own and customize the Metro configs freely. The generated files delegate to one/babel-preset and one/metro-config. See Metro Mode for details.
one metro-eject [options]
--force # overwrite existing filesone daemon
Multi-app development server proxy. Coordinates several One dev servers behind one port.
one daemon [subcommand] [options]
start (default) # start the daemon
stop # stop the daemon
status # show running servers and active routes
route # route a bundle ID to a server slot
--port <port> # port to listen on (default: 8081)
--host <host> # host to bind to (default: 0.0.0.0)
--app <bundle-id> # bundle ID for the route subcommand
--slot <n> # slot number for the route subcommand
--project <path> # project path for the route subcommand
--tui # show TUI (default: true if TTY)one updates publish
Bundle the native JS and publish an over-the-air update directory with manifest.json and assets. Requires native.app.updates.runtimeVersion in your Vite config, so the binary only takes updates published for its own runtime version. See Over-the-air Updates for the full flow.
one updates publish [options]
--platform ios|android # which platform to bundle for (required)
--out <dir> # directory to write manifest.json and assets into (required, must be empty)
--metadata key=value # manifest metadata (repeatable, values parse as JSON scalars)
--intermediatesOut <dir> # separate directory for the local debug bundle and composed source mapApps Onestack.Dev Data Docs Common Issues (`apps/onestack.dev/data/docs/common-issues.mdx`)
title: Common Issues
This document collects tips and tricks for debugging common issues. If you'd like to see a workaround documented here, please submit it to our GitHub issues.
General errors when server rendering
If you see an error while rendering from the server, it can often be hard to diagnose. There's a few ways to debug it.
First, we've found turning off Vite external dependencies entirely will often help, what it does is make Vite use esbuild and apply CommonJS transforms for compatibility, transforming all of them into your .vite directory. You can enable it like so:
{
ssr: {
noExternal: true
}
}The reason we don't turn this on by default it is can cause other issues, namely if a node module relies on, say, __dirname, or __filename, then when they are transformed into the .vite folder these paths will change. Another reason we don't turn it on by default is that it can slow things down.
If you do find the individual module that is causing an issue, you can also configure it quickly using the patches option in the One Vite plugin configuration.
Uncaught SyntaxError: The requested module ... does not provide an export named 'default'
In general you want to add any dependency like this to optimizeDeps.exclude in your Vite config.
`require` is not defined
You can try using one of the commonjs plugins, either vite-require or vite-plugin-commonjs.
Common React Native package issues
When using bundlers like Vite, Rollup, or esbuild, developers often encounter issues with React Native packages originally designed to work only for Metro. Metro’s tolerant nature allows certain practices that other bundlers may not support, such as:
- Including Flow types in distributed
.jsfiles. - Including JSX syntax in
.jsfiles instead of.jsx. - Releasing packages as ESM-only without proper declarations in
package.json(e.g., missing"type": "module") or appropriate.mjsextensions. - Omitting import path extensions in distributed ESM, which can prevent Node.js from running those modules correctly, and break SSR.
These issues can break builds and require specific handling to make the packages compatible with non-Metro bundlers. Related error messages you might encounter includes:
The JSX syntax extension is not currently enabled.The esbuild loader for this file is currently set to "js" but it must be set to "jsx" to be able to parse JSX syntax.SWC failed to transform filefollowing with a syntax error parsing something likeimport type.
To workaround these issues, you can use the package patching feature of One to fix the packages that are causing problems. Here's an example:
export default {
plugins: [
one({
// ...
patches: {
'react-native-vector-icons': {
'**/*.js': [
'jsx', // Transpile JSX in .js files
'flow', // Remove flow types
],
},
},
}),
],
}For more details, see the patches section in Configuration.
If you've resolved an issue with a patch, we'd love for you to share your patch configuration. Alternatively, feel free to reach out, and we'll gladly assist in finding a solution to get things working.
Making fetch() requests on native platforms
When using fetch() on native platforms (iOS/Android), relative URLs behave differently than on web. You need to use the ONE_SERVER_URL environment variable to construct absolute URLs for API calls.
URL behavior by platform:
Web – works as expected:
- Relative URL (
/api/hello) ✅ - localhost URL (
http://localhost:8081/api/hello) ✅ - Local IP URL (
http://192.168.0.[x]:8081/api/hello) ✅
iOS Simulator:
- Relative URL (
/api/hello) ❌ – Network request failed - localhost URL (
http://localhost:8081/api/hello) ✅ - Local IP URL (
http://192.168.0.[x]:8081/api/hello) ✅
Real iOS Device:
- Relative URL (
/api/hello) ❌ – Network request failed - localhost URL (
http://localhost:8081/api/hello) ❌ – Network request failed - Local IP URL (
http://192.168.0.[x]:8081/api/hello) ✅
Solution
Use the ONE_SERVER_URL environment variable to construct absolute URLs:
// ❌ Won't work on native
const response = await fetch('/api/hello')
// ✅ Works everywhere
const response = await fetch(`${process.env.ONE_SERVER_URL}/api/hello`)One automatically sets ONE_SERVER_URL in development mode to the current running server URL (e.g., http://0.0.0.0:8081). For production builds, you'll need to set this environment variable yourself.
Application has not been registered
One calls AppRegistry.registerComponent for you to set up your React Native entry component, using native.app.name (falling back to native.key).
If your React Native app is erroring on startup, make sure your One Vite plugin names the app, and that the name matches the key your native app container was built with:
import { one } from 'one/vite'
export default {
plugins: [
one({
native: {
app: {
name: 'my-app',
},
},
})
]
}Apps Onestack.Dev Data Docs Components Drawer (`apps/onestack.dev/data/docs/components-Drawer.mdx`)
title: " "
The Drawer component provides a slide-out navigation panel, commonly used for app-wide navigation menus. Import it from one/drawer to keep the bundle size minimal when not using the drawer.
This component should only be rendered inside a _layout.tsx file, where it will serve as the location that children will render for routes below the layout.
Drawer wraps React Navigation's Drawer Navigator and accepts the same props.
Basic Usage
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { Drawer } from 'one/drawer'
export default function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer />
</GestureHandlerRootView>
)
}With Screen Options
You can customize the drawer appearance and behavior using screenOptions:
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { Drawer } from 'one/drawer'
export default function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer
screenOptions={{
headerShown: true,
drawerType: 'front',
// improve swipe gesture feel
swipeEdgeWidth: 80,
swipeMinDistance: 20,
}}
>
<Drawer.Screen
name="index"
options={{
drawerLabel: 'Home',
title: 'Home',
}}
/>
<Drawer.Screen
name="settings"
options={{
drawerLabel: 'Settings',
title: 'Settings',
}}
/>
</Drawer>
</GestureHandlerRootView>
)
}Programmatic Control
You can open and close the drawer programmatically using navigation:
import { useNavigation, DrawerActions } from '@react-navigation/native'
function MyScreen() {
const navigation = useNavigation()
return (
<Button
onPress={() => navigation.dispatch(DrawerActions.openDrawer())}
title="Open Drawer"
/>
)
}Drawer Types
The drawerType option controls how the drawer appears:
'front'(default) - Drawer slides over the content'back'- Content slides to reveal drawer behind'slide'- Both drawer and content slide together'permanent'- Drawer is always visible (good for tablets)
Common Options
| Option | Type | Default | Description |
|---|---|---|---|
drawerType |
'front' | 'back' | 'slide' | 'permanent' |
'front' |
How the drawer animates |
swipeEdgeWidth |
number |
32 |
Width of the swipe area from screen edge |
swipeMinDistance |
number |
60 |
Minimum swipe distance to open drawer |
swipeEnabled |
boolean |
true |
Enable/disable swipe gestures |
drawerPosition |
'left' | 'right' |
'left' |
Which side the drawer appears |
headerShown |
boolean |
true |
Show/hide the header |
On web
On web, <Drawer> renders headless: only the focused screen, no sidebar. Open state lives in navigation state - drive your own sidebar with useDrawer, which gives you the screen list, the focused screen, isOpen, and open/close/toggle actions:
import { Drawer } from 'one/drawer'
import { Link, useDrawer } from 'one'
function WebDrawerLayout() {
const { screens, focused, isOpen, close, toggle } = useDrawer()
return (
<>
<button onClick={toggle}>Menu</button>
{isOpen && (
<nav>
{screens.map((screen) => (
<Link key={screen.key} href={screen.href} onPress={close}>
{screen.options.drawerLabel ?? screen.options.title ?? screen.name}
</Link>
))}
</nav>
)}
{focused.element}
</>
)
}
export default function Layout() {
return (
<Drawer>
<Drawer.Screen name="index" options={{ drawerLabel: 'Home' }} />
<Drawer.Screen name="settings" options={{ drawerLabel: 'Settings' }} />
<WebDrawerLayout />
</Drawer>
)
}A component placed as a child of <Drawer> replaces the headless default on web and is ignored on native. See the headless web navigators guide for more.
Dependencies
The Drawer requires these optional peer dependencies to be installed:
npm install @react-navigation/drawer react-native-gesture-handler react-native-reanimatedThese are marked as optional peer dependencies in one, so they won't be installed automatically. This keeps bundle sizes smaller for apps that don't use the Drawer.
For more configuration options, see the React Navigation Drawer documentation.
Apps Onestack.Dev Data Docs Components Head (`apps/onestack.dev/data/docs/components-Head.mdx`)
title: ""
The Head component lets you render elements into the document <head> on web, and integrates with native platform features like Apple Handoff and Spotlight Search on iOS.
Usage
import { Head, useLoader } from 'one'
export async function loader({ params }) {
const post = await getPost(params.slug)
return { post }
}
export default function BlogPost() {
const { post } = useLoader(loader)
return (
<>
<Head>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:image" content={post.image} />
</Head>
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
)
}Props
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Standard HTML head elements like <title>, <meta>, <link>, etc. |
Supported Elements
The Head component accepts standard HTML head elements:
<title>- Page title<meta>- Meta tags (description, Open Graph, Twitter Cards, etc.)<link>- Stylesheets, favicons, preload hints<script>- External scripts<style>- Inline styles
<Head>
{/* Page title */}
<title>My Page Title</title>
{/* Meta tags */}
<meta name="description" content="Page description" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* Open Graph */}
<meta property="og:title" content="My Page" />
<meta property="og:description" content="Page description" />
<meta property="og:image" content="https://example.com/image.png" />
<meta property="og:url" content="https://example.com/page" />
{/* Twitter Cards */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="My Page" />
{/* Favicon */}
<link rel="icon" href="/favicon.ico" />
{/* Preload fonts */}
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossOrigin="" />
</Head>Dynamic Metadata
Use loader data or component state to set dynamic metadata:
import { Head, useLoader, useParams } from 'one'
export async function loader({ params }) {
const product = await getProduct(params.id)
return { product }
}
export default function ProductPage() {
const { product } = useLoader(loader)
const ogImageUrl = `/api/og?title=${encodeURIComponent(product.name)}&price=${product.price}`
return (
<>
<Head>
<title>{product.name} | My Store</title>
<meta name="description" content={product.description} />
<meta property="og:title" content={product.name} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="product:price:amount" content={String(product.price)} />
<meta property="product:price:currency" content="USD" />
</Head>
{/* Page content */}
</>
)
}Platform Behavior
Web
On web, Head renders its children directly into the document's <head> element. Multiple Head components can be used across your app - they will all contribute to the final head content.
iOS (Native)
On iOS, Head integrates with native platform features:
- Apple Handoff: Continue browsing on other Apple devices
- Spotlight Search: Make your content searchable in iOS Spotlight
Enable these features using special meta properties:
<Head>
<title>My Article</title>
<meta property="og:title" content="My Article" />
<meta property="og:description" content="Article description" />
<meta property="og:url" content="https://example.com/article" />
{/* Enable Apple Handoff */}
<meta property="expo:handoff" content="true" />
{/* Enable Spotlight Search indexing */}
<meta property="expo:spotlight" content="true" />
</Head>| Meta Property | Description |
|---|---|
expo:handoff |
Set to "true" to enable Apple Handoff for this page |
expo:spotlight |
Set to "true" to index this page in iOS Spotlight Search |
Android
On Android, Head currently has no effect. Native metadata features may be added in future versions.
Creating a Reusable Head Component
For consistent metadata across your app, create a wrapper component:
import { Head } from 'one'
type HeadInfoProps = {
title: string
description?: string
image?: string
url?: string
}
export function HeadInfo({ title, description, image, url }: HeadInfoProps) {
const fullTitle = `${title} | My App`
return (
<Head>
<title>{fullTitle}</title>
{description && <meta name="description" content={description} />}
{/* Open Graph */}
<meta property="og:title" content={fullTitle} />
{description && <meta property="og:description" content={description} />}
{image && <meta property="og:image" content={image} />}
{url && <meta property="og:url" content={url} />}
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
{description && <meta name="twitter:description" content={description} />}
{image && <meta name="twitter:image" content={image} />}
</Head>
)
}Use it in your pages:
import { HeadInfo } from '~/features/HeadInfo'
export default function AboutPage() {
return (
<>
<HeadInfo
title="About Us"
description="Learn about our company"
image="/images/about-og.png"
/>
{/* Page content */}
</>
)
}Related
- Dynamic OpenGraph Images - Generate dynamic OG images
- OpenGraph Images - Social preview images for your pages
Apps Onestack.Dev Data Docs Components Link (`apps/onestack.dev/data/docs/components-Link.mdx`)
title: ""
Render a link to another page.
Props
Note that Href is a string constant typed based on your file structure.
Link accepts all React Native Text props except href, plus the pressable props disabled, onLongPress, onPressIn and onPressOut, plus the link-specific props below:
/* Detailed source-code truncated for AI context efficiency. */Apps Onestack.Dev Data Docs Components LoadProgressBar (`apps/onestack.dev/data/docs/components-LoadProgressBar.mdx`)
title: " "
Render this component to show a loading progress bar at the top of the browser frame while pages are loading. This is a web-only component and should be rendered in your root _layout.tsx file.
import { LoadProgressBar, Slot } from 'one'
export default function Layout() {
return (
<>
<LoadProgressBar />
<Slot />
</>
)
}Props
export type LoadProgressBarProps = {
/** How long after a navigation to wait before showing the progress bar (in ms) */
startDelay?: number
/** How long after a navigation completes to wait before hiding the progress bar (in ms) */
finishDelay?: number
/** The starting percent it should show the loading state at */
initialPercent?: number
/** How often the progress bar should update (in ms) */
updateInterval?: number
/** How often to skip an update (checked each during the loop) */
sporadicness?: number
/** Pass style to the inner View */
style?: ViewProps['style']
/** Pass onLayout to the inner View */
onLayout?: ViewProps['onLayout']
/** Pass children to the inner View */
children?: ViewProps['children']
}Apps Onestack.Dev Data Docs Components Presentations (`apps/onestack.dev/data/docs/components-Presentations.mdx`)
title: ""
Presentations sets the components that render sheet and modal presentations on web. Mount it once near the root of your app.
By default a presentation screen renders on web with no chrome at all: just the screen's content, inline, with no backdrop or floating position. Presentations is how you plug in a real sheet or modal (Tamagui Sheet, Vaul, Radix Dialog, or your own).
Usage
// app/_layout.tsx
import { Presentations, Slot } from 'one'
import { Modal } from '~/interface/Modal'
import { Sheet } from '~/interface/Sheet'
export default function Layout() {
return (
<Presentations web={{ sheet: Sheet, modal: Modal }}>
<Slot />
</Presentations>
)
}Any <Stack.Screen> with a presentation option picks these up automatically, using the same vocabulary you'd write for native:
<Stack>
<Stack.Screen
name="compose"
options={{ presentation: 'sheet', sheetAllowedDetents: [0.6, 1] }}
/>
</Stack>Writing a presentation component
Each one is an ordinary component, so hooks, context, and refs all work normally:
import type { SheetPresentationProps } from 'one'
export function Sheet({ open, onOpenChange, options, children }: SheetPresentationProps) {
const theme = useTheme()
return (
<MySheet open={open} onOpenChange={onOpenChange} snapPoints={options.sheetAllowedDetents}>
{children}
</MySheet>
)
}| Prop | Type | Description |
|---|---|---|
open |
boolean |
Whether the presentation should be visible. Starts false on mount and flips to true on the next commit, so enter animations play. |
onOpenChange |
(open: boolean) => void |
Call with false when your UI dismisses. Pops the screen. |
options |
SheetPresentationOptions / ModalPresentationOptions |
The presentation options set on the screen. |
screen |
ScreenEntry |
The full screen entry: name, params, complete options, href. |
children |
ReactNode |
The screen's content. |
options is a narrowed view of the screen's options, taken straight from Stack.Screen, using the same values you'd set for native:
- Sheet:
sheetAllowedDetents,sheetGrabberVisible,sheetCornerRadius,sheetExpandsWhenScrolledToEdge,gestureEnabled,title - Modal:
gestureEnabled,title
Everything the screen declared is still on screen.options if you need a key that isn't narrowed.
Which presentations map where
| Component | presentation values |
|---|---|
sheet |
'sheet', 'formSheet', 'pageSheet' |
modal |
Any other non-'card'/'push' value ('modal', 'containedModal', 'fullScreenModal', 'transparentModal', ...) |
Omit one and that presentation keeps rendering its content inline with no chrome.
Scoping
Presentations is just context, so nesting one further down the tree overrides it for that subtree only:
<Presentations web={{ sheet: Sheet }}>
<Slot />
<Presentations web={{ sheet: SpecialSheet }}>
{/* screens rendered under here use SpecialSheet instead */}
</Presentations>
</Presentations>Native
On native, presentations are handled by react-navigation, so the web components never run there. A native option that lets you swap the OS sheet for your own component is not implemented yet.
Related
Apps Onestack.Dev Data Docs Components Protected (`apps/onestack.dev/data/docs/components-Protected.mdx`)
title: ""
The `` component allows you to declaratively hide routes based on a boolean condition. When the guard prop is false, wrapped screens are completely filtered out from the navigator - they won't appear in the navigation state and cannot be navigated to.
This is useful for authentication flows, feature flags, role-based access, or any scenario where routes should be conditionally available.
import { Stack, Protected } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function Layout() {
const { session } = useSession()
return (
<Stack>
<Stack.Screen name="login" options={{ title: 'Login' }} />
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Protected guard={!!session}>
<Stack.Screen name="dashboard" options={{ title: 'Dashboard' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Protected>
</Stack>
)
}When session is null/undefined, the dashboard and settings routes don't exist in the navigation tree. Attempts to navigate to them will be blocked.
Redirect Unauthorized Navigation
Set redirectTo to send unauthorized navigation to a specific route. One uses a replace navigation, so the protected route is not left in history:
<Stack>
<Stack.Screen name="login" />
<Protected guard={!!session} redirectTo="/login">
<Stack.Screen name="dashboard" />
<Stack.Screen name="settings" />
</Protected>
</Stack>If the guard becomes false while a wrapped route is focused, One redirects to redirectTo and removes guarded screens from the navigator history. Without redirectTo, the navigator uses its first available route.
Works with Any Navigator
`` works with Stack, Tabs, Drawer, and the base Navigator component:
import { Tabs, Protected } from 'one'
export default function TabLayout() {
const { isAdmin } = useUser()
return (
<Tabs>
<Tabs.Screen name="home" />
<Tabs.Screen name="profile" />
<Protected guard={isAdmin}>
<Tabs.Screen name="admin" />
</Protected>
</Tabs>
)
}Nested Guards
You can nest `` components. Routes are only shown when all parent guards are true:
<Stack>
<Stack.Screen name="public" />
<Protected guard={isLoggedIn}>
<Stack.Screen name="dashboard" />
<Protected guard={isPremium}>
<Stack.Screen name="premium-features" />
</Protected>
</Protected>
</Stack>In this example:
publicis always availabledashboardrequiresisLoggedInto be truepremium-featuresrequires bothisLoggedInANDisPremiumto be true
Comparison with Redirect
There are two main approaches to protecting routes:
| Approach | When to Use |
|---|---|
| `` | Routes should not exist when unauthorized, with an optional redirect target |
<Redirect /> |
Routes exist but redirect unauthorized users |
Use `` when routes should be completely hidden - they won't appear in tab bars or remain in navigation history. Add redirectTo when unauthorized navigation should land on a specific route.
Use <Redirect /> when you want to intercept navigation and send users to a login page. This is better for deep linking scenarios where you want to redirect to login, then back to the original destination.
You can combine both approaches:
export default function AppLayout() {
const { session, isLoading } = useSession()
if (isLoading) return null
if (!session) return <Redirect href="/login" />
return (
<Stack>
<Stack.Screen name="index" />
<Protected guard={session.role === 'admin'}>
<Stack.Screen name="admin" />
</Protected>
</Stack>
)
}Props
| Prop | Type | Description |
|---|---|---|
guard |
boolean |
When false, all wrapped screens are filtered out |
redirectTo |
Href |
Route used when the guard fails. Defaults to the navigator's first available route |
children |
ReactNode |
<Stack.Screen />, <Tabs.Screen />, or other `` elements |
Related
- Authentication Guide - Full auth implementation patterns
- Stack - Stack navigator
- Tabs - Tab navigator
- Redirect - Redirect component
Apps Onestack.Dev Data Docs Components Redirect (`apps/onestack.dev/data/docs/components-Redirect.mdx`)
title: " "
Redirect to another route. Useful for protecting routes or redirecting from old paths.
Usage
import { Redirect } from 'one'
export default function ProtectedPage() {
const { user } = useAuth()
if (!user) {
return <Redirect href="/login" />
}
return <Dashboard user={user} />
}Props
| Prop | Type | Description |
|---|---|---|
href |
Href |
The typed path to redirect to |
Behavior
<Redirect> navigates with router.replace as soon as it mounts. It's designed to be rendered conditionally from a layout guard, so it guards against the two ways that can otherwise turn into a redirect loop:
- Fires once per mount. Re-renders, and re-focusing the screen (navigating away and back), won't re-issue the navigation.
- De-duplicates across remounts. An auth gate that flips between
<Redirect>and<Slot>/nullwhile auth state settles remounts<Redirect>repeatedly, and each remount is a fresh instance. Becauserouter.replaceis async (it preloads the target route before dispatching), every remount would otherwise fire another concurrent navigation and throwMaximum update depth exceeded.<Redirect>shares an in-flight guard across instances so only one navigation is dispatched until it lands; the guard clears once it settles, so a later redirect to the same href still fires.
Because of this you don't need a hand-rolled useEffect + useRef redirect in layout guards. Prefer the declarative <Redirect>.
Apps Onestack.Dev Data Docs Components SafeAreaView (`apps/onestack.dev/data/docs/components-SafeAreaView.mdx`)
title: " "
SafeAreaView renders content within the safe area boundaries of a device, avoiding notches, status bars, and home indicators.
One.UI.SafeArea is One's shared safe area API. It uses One's native host on iOS and
Android and CSS environment insets on web, with no separate safe area package in your app.
The same view is also exported directly as SafeAreaView from one.
Usage
import { One, Slot } from 'one'
export default function Layout() {
return (
<One.UI.SafeArea.View style={{ flex: 1 }}>
<Slot />
</One.UI.SafeArea.View>
)
}Props
One.UI.SafeArea.View extends all standard View props and adds:
| Prop | Type | Default | Description |
|---|---|---|---|
mode |
'padding' | 'margin' |
'padding' |
How to apply the safe area insets |
edges |
Edge[] |
All edges | Which edges to apply insets to |
Examples
Apply to Specific Edges
import { One } from 'one'
// Only apply to top (useful for screens with bottom tabs)
<One.UI.SafeArea.View edges={['top']}>
{/* Content */}
</One.UI.SafeArea.View>
// Only apply to top and bottom
<One.UI.SafeArea.View edges={['top', 'bottom']}>
{/* Content */}
</One.UI.SafeArea.View>Use Margin Instead of Padding
<One.UI.SafeArea.View mode="margin" style={{ flex: 1 }}>
{/* Content */}
</One.UI.SafeArea.View>Hooks
useSafeAreaInsets
Get the current safe area insets for custom layouts:
import { useSafeAreaInsets } from 'one'
function MyComponent() {
const insets = useSafeAreaInsets()
return (
<View style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}>
{/* Content */}
</View>
)
}Returns { top, right, bottom, left } in pixels.
Platform Behavior
- Web: Reads CSS
env(safe-area-inset-*)values. - iOS: Reads native safe area APIs for the status bar, notch or Dynamic Island, and home indicator.
- Android: Reads native window insets for status bars, navigation bars, and display cutouts.
One mounts the safe area provider at its root on every platform, so there is no setup. Libraries that import react-native-safe-area-context, such as React Navigation, resolve to One's safe area and read the same insets. If your app declares react-native-safe-area-context in its own package.json, One leaves that package alone and your copy is used.
Related
Apps Onestack.Dev Data Docs Components ScrollBehavior (`apps/onestack.dev/data/docs/components-ScrollBehavior.mdx`)
title: " "
One automatically handles scroll restoration to provide a native browser-like experience. When you navigate to a new page, scroll resets to the top. When you press back/forward, your previous scroll position is restored.
The ScrollBehavior component lets you customize this behavior for special cases.
Default Behavior
Out of the box, One handles:
- Hash scrolling: Scrolls to
#element-idwhen present in the URL - New page navigation: Scrolls to top when navigating to a new route
- Back/forward navigation: Restores the previous scroll position
- Session persistence: Scroll positions are saved to sessionStorage
You don't need to add ScrollBehavior unless you want to change this behavior.
Usage
Add ScrollBehavior once in your root layout:
import { ScrollBehavior, Slot } from 'one'
export default function Layout() {
return (
<>
<ScrollBehavior />
<Slot />
</>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
disable |
boolean | 'restore' |
false |
Disable scroll behavior entirely or just restoration |
Disable Options
// Disable all scroll behavior
<ScrollBehavior disable />
// Disable only scroll restoration on back/forward
// (still scrolls to top on new navigation)
<ScrollBehavior disable="restore" />Scroll Groups
For complex layouts like tabs or dashboards, you may want child routes to share a scroll position. Use the useScrollGroup hook to create a scroll group:
import { Slot, useScrollGroup } from 'one'
export default function DashboardLayout() {
// All routes under /dashboard will share scroll position
useScrollGroup()
return (
<div>
<Sidebar />
<main>
<Slot />
</main>
</div>
)
}Now when navigating between /dashboard/overview and /dashboard/settings, the scroll position is preserved instead of resetting to the top.
Custom Scroll Group Path
You can specify a custom group path:
import { useScrollGroup } from 'one'
function MyLayout() {
// Create a scroll group for a specific path
useScrollGroup('/products')
return <Slot />
}Navigation-Level Control
Disable scroll behavior for an individual navigation by passing scroll: false to the router:
import { useRouter } from 'one'
function MyComponent() {
const router = useRouter()
const handleClick = () => {
router.push('/next-page', { scroll: false })
}
return <button onClick={handleClick}>Navigate without scrolling</button>
}How It Works
- On navigation start: Current scroll position is saved to sessionStorage keyed by pathname
- On navigation complete:
- If URL has a hash (
#section), scrolls to that element - If back/forward navigation, restores saved position
- If within a scroll group, preserves group position
- Otherwise, scrolls to top
- If URL has a hash (
Scroll positions are stored per-pathname in sessionStorage, so they persist across page refreshes within a session but not across browser sessions.
Platform Support
| Platform | Support |
|---|---|
| Web | Full support |
| React Native | Not applicable (no window scroll) |
ScrollBehavior is a web-only component. On native platforms, scroll restoration is handled by the native scroll views themselves.
Common Use Cases
Infinite Scroll Lists
Preserve scroll position when users navigate away and back:
import { Slot, useScrollGroup } from 'one'
export default function FeedLayout() {
// Preserve scroll in the feed when viewing details
useScrollGroup()
return <Slot />
}Tab-Based Navigation
Keep each tab's scroll position independent:
import { Tabs, useScrollGroup } from 'one'
export default function TabLayout() {
useScrollGroup()
return (
<Tabs>
<Tabs.Screen name="home" />
<Tabs.Screen name="search" />
<Tabs.Screen name="profile" />
</Tabs>
)
}Disable for Modal Routes
Don't scroll when opening modals:
router.push('/modal', { scroll: false })Related
- Link - Navigation component with scroll control
- useRouter - Programmatic navigation with scroll options
- Layouts - Layout patterns for scroll groups
Apps Onestack.Dev Data Docs Components Slot (`apps/onestack.dev/data/docs/components-Slot.mdx`)
title: " "
This component should only be rendered inside a _layout.tsx file, where it will serve as the location that children will render for routes below the layout.
This is the simplest option for rendering children, it does not create a Stack, Drawer, or Tab view, instead it just renders the children directly.
import { Slot } from 'one'
export default function Layout() {
return (
<Slot />
)
}NamedSlot
For intercepting routes, layouts render one <NamedSlot> per parallel @slotName directory. It renders the intercepted route when an intercept is active, and its children (or the slot's default.tsx) otherwise:
import { NamedSlot } from 'one'
export default function Layout({ children, modal }) {
return (
<>
{children}
<NamedSlot name="modal">{modal}</NamedSlot>
</>
)
}name matches the @modal directory name. Pass layoutContextKey to scope slot state to one layout so duplicate modals do not render across layouts.
Close an intercepted modal with closeIntercept() instead of router.back(). It restores the pre-intercept URL and returns true when an intercept was active, false otherwise:
import { closeIntercept } from 'one'
function CloseButton() {
return <button onClick={() => closeIntercept()}>Close</button>
}Use isInterceptedNavigation() to check whether the current history entry came from an interception.
See NamedSlot above for the full file conventions.
Apps Onestack.Dev Data Docs Components Stack (`apps/onestack.dev/data/docs/components-Stack.mdx`)
title: " "
This component should only be rendered inside a _layout.tsx file, where it will serve as the location that children will render for routes below the layout.
Stack is simply a React Navigation Native Stack view and accepts the same props as React Navigation.
import { Stack } from 'one'
import { Button } from 'react-native'
export default function Layout() {
return (
<Stack
screenOptions={{
headerRight() {
return (
<Button title="Settings" onPress={() => {}} />
)
},
}}
/>
)
}Stack.Screen
You can customize the children of the Stack in your layout by passing a children prop to Stack has Stack.Screen elements, like so:
import { Stack } from 'one'
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Feed' }} />
<Stack.Screen name="[id]" options={{ title: 'Post' }} />
<Stack.Screen
name="sheet"
options={{
presentation: 'formSheet',
gestureDirection: 'vertical',
animation: 'slide_from_bottom',
headerShown: false,
}}
/>
</Stack>
)
}The name must match the full name of the file inside app, without the extension but including groups.
In this example we are setting index, [id], and sheet screens, which would correspond to index.tsx and [id].tsx and sheet.tsx pages in the same directory.
This is a convenient way to configure settings for each page up front, but you could also render Stack.Screen inside each individual page so you can access data loaded inside that page. The upside of doing it in the layout is that it will configure things before any stack animation runs on enter, with the downside being that you can't access page-level data.
The options property passes to the React Navigation NativeStack, and so takes the same options.
Header Composition API
For more declarative header configuration, use the compositional API with Stack.Header and its child components:
import { Stack } from 'one'
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index">
<Stack.Header blurEffect="regular">
<Stack.Header.Title large>Articles</Stack.Header.Title>
<Stack.Header.SearchBar placeholder="Search..." />
</Stack.Header>
</Stack.Screen>
<Stack.Screen name="[id]">
<Stack.Header>
<Stack.Header.Title>Post</Stack.Header.Title>
<Stack.Header.Right asChild>
<ShareButton />
</Stack.Header.Right>
</Stack.Header>
</Stack.Screen>
</Stack>
)
}Stack.Header
The container for header configuration. Props:
hidden- Hide the header entirelyblurEffect- iOS blur effect ('regular','prominent','systemMaterial', etc.)asChild- Render a completely custom header componentstyle- Style withbackgroundColor,shadowColor(set to'transparent'to hide)largeStyle- Style for large title mode
Stack.Header.Title
Configure the header title:
<Stack.Header.Title large>My Title</Stack.Header.Title>Props:
children- The title textlarge- Enable iOS large title modestyle- Text style (fontWeight,fontSize,color,textAlign)largeStyle- Text style for large title mode
Stack.Header.BackButton
Configure the back button:
<Stack.Header.BackButton hidden />
<Stack.Header.BackButton displayMode="minimal">Back</Stack.Header.BackButton>Props:
children- Custom back button texthidden- Hide the back buttondisplayMode-'default','generic', or'minimal'withMenu- Enable long-press menu on iOSsrc- Custom back button imagestyle- Back button title style
Stack.Header.Left / Stack.Header.Right
Add custom components to the header:
<Stack.Header.Left asChild>
<MenuButton />
</Stack.Header.Left>
<Stack.Header.Right asChild>
<SettingsButton />
</Stack.Header.Right>Props:
asChild- Required to render custom childrenchildren- Your custom component
Stack.Header.SearchBar
Add an iOS-style search bar:
<Stack.Header.SearchBar
placeholder="Search articles..."
autoCapitalize="none"
placement="stacked"
/>Props:
placeholder- Placeholder textautoCapitalize-'systemDefault','none','words','sentences','characters'placement-'automatic','inline','stacked','integrated','integratedButton','integratedCentered'hideWhenScrolling- Hide when scrollingobscureBackground- Obscure background when active
Default Header for All Screens
You can set a default header for all screens by placing Stack.Header directly inside Stack:
<Stack>
<Stack.Header blurEffect="regular">
<Stack.Header.BackButton displayMode="minimal" />
</Stack.Header>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Stack.Screen name="profile" options={{ title: 'Profile' }} />
</Stack>Toolbar Composition API
Native toolbar items use the same compound style as the header. Stack.Toolbar
with placement="left" or placement="right" compiles to native header
items on iOS, and placement="bottom" (the default) renders a native bottom
toolbar from screen content:
<Stack.Screen name="index">
<Stack.Toolbar placement="left">
<Stack.Toolbar.Button icon="pencil" onPress={edit}>
Edit
</Stack.Toolbar.Button>
</Stack.Toolbar>
</Stack.Screen>See Toolbar and Menus for the full toolbar reference,
including menus, the bottom toolbar, and the lower-level One.iOS.ToolbarHost
primitive.
Typing the composition API
The prop types for the header and toolbar components are exported from one:
import type {
StackHeaderBackButtonProps,
StackHeaderLeftProps,
StackHeaderProps,
StackHeaderRightProps,
StackHeaderSearchBarProps,
StackHeaderTitleProps,
StackToolbarButtonProps,
StackToolbarProps,
} from 'one'Shared element transitions
On native, <Stack> wraps each screen in Reanimated's SharedTransitionBoundary, active while the screen is focused. A view with the same sharedTransitionTag on two screens morphs between them on push, pop and the back swipe, with no setup in your screens:
import Animated from 'react-native-reanimated'
<Animated.Image source={photo} sharedTransitionTag={`photo-${id}`} />Native apps need react-native-reanimated installed (the starter templates include it) and its ENABLE_SHARED_ELEMENT_TRANSITIONS static feature flag on in package.json:
"reanimated": { "staticFeatureFlags": { "ENABLE_SHARED_ELEMENT_TRANSITIONS": true } }Web never loads Reanimated for this.
On web
On web, <Stack> renders headless: only the focused screen's element, no header, no chrome. Screens with a presentation (sheet, modal, formSheet, etc.) render as overlays above the base screen - by default inline with no floating chrome, or with real chrome once you mount Presentations.
Set keepMounted: true on a screen's options to keep it mounted (hidden via React 19.2's <Activity>) instead of unmounting it when it loses focus:
<Stack.Screen name="settings" options={{ keepMounted: true }} />To build your own web layout entirely (a custom header, transitions, anything), place a component as a child of <Stack> - it replaces the headless default on web and is ignored on native. Use useStack to get the full stack state.
See the headless web navigators guide for the full story, including how Presentations maps sheets and modals to real chrome.
Apps Onestack.Dev Data Docs Components Tabs (`apps/onestack.dev/data/docs/components-Tabs.mdx`)
title: ' '
This component should only be rendered inside a _layout.tsx file, where it will serve as the location that children will render for routes below the layout.
Tabs is a React Navigation Bottom Tabs view and accepts the same props as React Navigation. On iOS and Android it uses the bottom tab navigator. On web it uses One's headless implementation.
The screen name matches the route file name. One derives the link from the route tree.
import { Tabs } from 'one'
export default function Layout() {
return (
<Tabs>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
}}
/>
</Tabs>
)
}Native tabs
React Navigation owns the tab bar on iOS and Android. Use its bottom tab options directly, including tabBarIcon, tabBarSystemItem, tabBarMinimizeBehavior, and tabBarSelectionEnabled. An action tab listens for tabPress and disables route selection:
<Tabs.Screen
name="new"
listeners={{
tabPress() {
openComposer()
},
}}
options={{
title: 'New',
tabBarSelectionEnabled: false,
tabBarIcon: ({ focused }) => ({
type: 'sfSymbol',
name: focused ? 'plus.circle.fill' : 'plus.circle',
}),
}}
/>The href screen option belongs to One's web headless implementation. Native layouts reject it so a hidden or redirected tab cannot silently become an ordinary native tab. Put screens that should not appear as tabs in a parent Stack. If a web tab needs a custom target, define that option in the route layout's .web.tsx sibling.
On web
On web, <Tabs> renders headless: only the focused tab's screen, no tab bar. Your site's own navigation is your tab bar.
To build one, place a component as a child of <Tabs> - it replaces the headless default on web and is ignored on native. Use useTabs to get the tab list and the focused tab:
// app/(tabs)/_layout.web.tsx
import { Link, Tabs, useTabs } from 'one'
function WebTabBar() {
const { screens, focused } = useTabs()
return (
<>
<nav>
{screens.map((tab) => (
<Link
key={tab.name}
href={tab.href}
className={tab.isFocused ? 'active' : undefined}
>
{tab.options.title}
</Link>
))}
</nav>
{focused.element}
</>
)
}
export default function Layout() {
return (
<Tabs>
<Tabs.Screen name="explore" options={{ title: 'Explore', href: '/explore' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile', href: '/profile' }} />
<WebTabBar />
</Tabs>
)
}See the headless web navigators guide for more, including how native and web can share the same layout file.
Apps Onestack.Dev Data Docs Configuration (`apps/onestack.dev/data/docs/configuration.mdx`)
title: Configuration
One is entirely configured through a Vite plugin:
/* Detailed source-code truncated for AI context efficiency. */Note that some of this configuration is ultimately stored into environment variables automatically, accessible in your apps and website. For TypeScript support, add /// <reference types="one/env" /> to get typed access to these variables.
alias
Per-environment module aliases that work during both Vite transforms and rolldown dep pre-bundling. Unlike Vite's resolve.alias, these are applied via resolveId hooks so they're respected by the optimizer.
Specify aliases per environment. More specific environments (client, ssr, ios, android) override the general ones (web, native).
export default {
plugins: [
one({
alias: {
// applies to client + ssr environments
web: {
'react-native-web': '@tamagui/react-native-web-lite',
},
// applies to ios + android environments
native: {
'some-package': 'some-package-native',
},
// overrides web for ssr only
ssr: {
'some-package': 'some-package-ssr',
},
},
}),
],
}Replacement values can be bare specifiers — One resolves them internally.
setupFile
Path to a JS or TS file to import before the rest of your app runs. One controls your root component, but you may want to run some initialization code before anything else.
export default {
plugins: [
one({
// Single file for all environments
setupFile: './setup.ts',
// Or different files per environment
setupFile: {
client: './setup.client.ts',
server: './setup.server.ts',
native: './setup.native.ts',
},
// Or with platform-specific native files
setupFile: {
client: './setup.client.ts',
server: './setup.server.ts',
ios: './setup.ios.ts',
android: './setup.android.ts',
},
}),
],
}native
The native object allows you to configure your native apps.
Pass false to completely disable native (iOS/Android) support. When disabled, One will not register the ios/android Vite environments, skip the Metro / React Native dev server plugins, and skip the rolldown native bundle pipeline — useful when you want to use One purely as a web framework.
export default {
plugins: [
one({
native: false,
}),
],
}native.key
One will set up your React Native app to run via AppRegistry.registerComponent(app.key).
This setting must match the key you gave the React Native app container you've built.
native.bundler
Which bundler serves the native dev bundle. Options are:
'vite'(default): Runs Rolldown with OXC transforms and no Babel'metro': Runs Metro, also with no Babel by default
Use Metro when a dependency requires it. You can also set the ONE_METRO_MODE environment variable to override this setting to 'metro' for a single run.
For detailed information including limitations, configuration options, and performance considerations, see the Metro Mode documentation.
Note that native.bundler only affects dev. one build --platform ios and --platform android always produce the native bundle through the Rolldown path.
Default: 'vite'
native.bundlerOptions
Options for whichever bundler native.bundler selects. With 'vite':
export default {
plugins: [
one({
native: {
bundlerOptions: {
plugins: [myRolldownPlugin()],
define: {
__MY_FLAG__: true,
},
},
},
}),
],
}plugins are Rolldown plugins run on the native bundle in both dev and build. The native bundler is its own Rolldown instance rather than a Vite environment, so plugins in your top-level plugins array don't reach it unless they ship a native form, as @tamagui/vite-plugin does for its compiler. Those run on native automatically. This option is how you add any other plugin, and a plugin listed here takes the place of a top-level one with the same name.
define follows Vite's rules: a string value is inserted as a raw expression, anything else is JSON stringified. It's deliberately separate from your Vite define rather than inherited from it, because plugins add web-only defines there. @tamagui/vite-plugin sets _WORKLET: false for Reanimated on web, and inheriting that on native would statically kill every worklet.
With 'metro', see the Metro Mode documentation for nativeTransformModules, nativeTransforms, startup, and the Metro config passthroughs.
Imported functions in worklets
For Rolldown, workletImportsPlugin from vxrn makes selected exports from a pure JavaScript package callable inside ordinary worklets:
import { one } from 'one/vite'
import { workletImportsPlugin } from 'vxrn'
export default {
plugins: [
one({
native: {
bundler: 'vite',
bundlerOptions: {
plugins: [workletImportsPlugin({ remend: ['default'] })],
},
},
}),
],
}Keep your usual import remend from 'remend' in application code. The plugin bundles the selected exports and their dependencies into worklets, so they can run on a separate worklet runtime. This is an explicit opt-in that adds bundled code and serialized worklet code for each configured entry.
Select synchronous functions that work without a receiver or native runtime dependencies. The replacement module exposes only the selected exports. Function identity, name, length, attached properties, and constructor behavior are not preserved. Each entry owns its dependency graph, so module state is not shared with other configured entries or ordinary imports through another path.
Initialization happens lazily once per runtime and live module version. Editing a dependency rebuilds the worklet module during development; retained worklets keep their original version. Restart the dev server after changing the plugin configuration or package export mappings.
Node built-ins, React Native runtime packages, dynamic imports, and unsupported free runtime globals are rejected. The plugin cannot prove that a function is pure: the selected functions and their dependencies must not rely on host objects, runtime-specific properties on globalThis, or asynchronous module initialization.
native.app
Typed native app manifest. name is the native target and AppRegistry key. one prebuild requires name with ios.bundleId and android.applicationId configured.
export default {
plugins: [
one({
native: {
app: {
name: 'OneApp',
ios: { bundleId: 'com.example.oneapp' },
android: { applicationId: 'com.example.oneapp' },
},
},
}),
],
}See the iOS Native Guide for the full setup.
native.suspendRoutes
Wrap each route screen in a React Suspense boundary on native. On native the JS bundle already contains all route modules, so the boundary only catches the async import() wrapper which resolves on the next microtick. Set this to false to remove the <Suspense fallback={null}> wrapper, which prevents a blank flash when JS-driven navigation animations slide a screen in before the fallback is replaced.
Default: true
native.css
Turns on react-native-css-interop support when importing CSS on native. This allows you to use CSS imports in your React Native code.
export default {
plugins: [
one({
native: {
css: true,
},
}),
],
}Default: false
web
The web object allows you to configure web specific settings.
web.defaultRenderMode
Choose between one of ssg, spa or ssr. The default is ssg.
Each mode has trade-offs:
ssgstands for "Static Site Generated", it is the default mode.
SSG is a great general purpose strategy, where your pages are rendered fully at build-time and turned into static HTML. This means you can get a "perfect" first load, where your app is fully rendered with all content, servable easily by a CDN.
The JS is sent alongside it, and React will hydrate your app and resume running from there. The downside is that you do have some complexity in needing to ensure your pages can render in node, the upside is increased performance and SEO on the web.
spastands for "Single Page App", which is the simplest strategy.
SPA mode will not render the page on the server at all, and instead only serve the JavaScript to render your page
to the users browser. This mode avoids some complexity - you don't have to make your React code
ssrstands for "Sever Side Rendered".
This mode will render your page on the server at the time the browser requests it, running loaders as well for each request.
This mode is likely the least useful, as it means that you must make a full trip to your database at the time of each request, and your client is left waiting for the full render from the server before it can show anything. That said, this mode can be useful if you have dynamically generated content but still want to have the best possible SEO, at the cost of some complexity in supporting server rendering.
redirects
You can apply server-level redirects. One serves your app with Hono in production, and so the redirect pattern is simple: your source will be passed to Hono.app.get(source). Any matching path segments that start with : will be replaced into the matching segments in destination. And the redirect will send a response type of 301 if permanent is true, otherwise it will be 302.
web.linkPrefetch
Controls link prefetching strategy for improved navigation performance.
export default {
plugins: [
one({
web: {
// Smart trajectory-based prefetching (default)
linkPrefetch: 'intent',
// Or choose a simpler strategy
linkPrefetch: 'viewport', // prefetch when links enter viewport
linkPrefetch: 'hover', // prefetch on mouseover only
linkPrefetch: false, // disable prefetching
},
}),
],
}Options:
'intent'(default): Uses mouse trajectory prediction to prefetch links before you hover them. Ray-casting detects when you're moving toward a link, with winner-takes-all scoring to avoid over-fetching in dense navigation areas. Falls back to hover for immediate interactions.'viewport': Prefetches links when they enter the viewport (with 100px margin). Good for content-heavy pages with many links.'hover': Traditional hover-based prefetching. Prefetches when you mouseover a link.false: Disables link prefetching entirely.
web.deploy
Deployment target for production builds. Determines how the server bundle is built.
export default {
plugins: [
one({
web: {
deploy: 'vercel', // or 'node' or 'cloudflare'
},
}),
],
}Options:
'node'(default): Standard Node.js server using Hono'vercel': Vercel serverless functions'cloudflare': Cloudflare Workers
web.inlineLayoutCSS
When enabled, inlines CSS content directly into the HTML instead of using <link> tags. This eliminates an extra network request for CSS, improving LCP (Largest Contentful Paint) scores.
export default {
plugins: [
one({
web: {
inlineLayoutCSS: true,
},
}),
],
}This inlines ALL CSS. For per-file control, use the .inline.css extension instead, which only inlines the files you mark.
Default: false
Code Splitting
One uses Rolldown for production builds and configures sensible code splitting defaults out of the box. By default, chunks smaller than 42KB are merged together to reduce the number of HTTP requests on page load.
You can customize the code splitting behavior through build.rolldownOptions.output.codeSplitting:
export default {
plugins: [
one({
// ... your one() config
}),
],
build: {
rolldownOptions: {
output: {
codeSplitting: {
// minimum chunk size in bytes before merging (default: 42000)
minSize: 42_000,
// maximum chunk size - splits chunks larger than this
maxSize: 500_000,
// group specific modules into named chunks
groups: [
{
name: 'vendor',
test: /node_modules/,
priority: 1,
minSize: 50_000,
// only create chunk if shared by 2+ entry points
minShareCount: 2,
},
],
},
},
},
},
}Why this matters: Without chunk merging, a large app can produce hundreds of tiny JS files (many under 1KB). Even with HTTP/2 multiplexing, this many requests saturates browser connections and increases total transfer overhead. The 42KB default strikes a balance between granular caching and fewer round-trips.
SPA page optimization: For SPA pages, One skips emitting <link rel="modulepreload"> hints in the HTML since the JS module graph handles its own lazy loading and route preloading happens on hover intent. This avoids saturating the connection with hundreds of preload hints that provide no benefit for client-rendered pages.
web.experimental_scriptLoading
Controls how scripts are loaded for improved performance. This is an experimental feature that can significantly improve Lighthouse scores by deferring non-critical JavaScript.
export default {
plugins: [
one({
web: {
experimental_scriptLoading: 'after-lcp',
},
}),
],
}Options:
'defer-non-critical': Critical scripts (framework entry, page, layouts) load immediately. Non-critical scripts (component imports, utilities) become modulepreload hints only, reducing network/CPU contention.'after-lcp': Scripts download immediately via modulepreload but execution is deferred until after first paint using double requestAnimationFrame. This allows the browser to paint the SSR content before executing JavaScript. Only applies to SSG pages.'after-lcp-aggressive': Omits initial JavaScript modulepreload hints so images and styles can use the connection first. The deferred bootstrap loads the entry and layout scripts; their imports load the remaining chunks. Best for pages with many chunks or slow networks. Only applies to SSG pages.
Default: undefined (all scripts load with async)
web.sitemap
Generate a sitemap.xml file automatically during build. This helps search engines discover and index your pages.
export default {
plugins: [
one({
web: {
// Simple: just enable it
sitemap: true,
// Or configure it
sitemap: {
// Base URL for sitemap entries (defaults to ONE_SERVER_URL env var)
baseUrl: 'https://example.com',
// Default priority for all routes (0.0 to 1.0)
priority: 0.7,
// Default change frequency
changefreq: 'weekly',
// Glob patterns for routes to exclude
exclude: ['/admin/*', '/internal/*'],
},
},
}),
],
}The sitemap automatically:
- Includes all page routes (SSG, SPA, SSR)
- Expands dynamic routes using their
generateStaticParamsoutput - Excludes API routes and not-found pages
Route-level overrides: Routes can export a sitemap object to customize their entry. See Routing Exports for details.
Base URL: If baseUrl isn't specified, One uses the ONE_SERVER_URL environment variable. If neither is set, paths are relative.
web.skewProtection
Skew protection detects when deployed assets have changed and handles stale client bundles gracefully instead of showing broken pages.
export default {
plugins: [
one({
web: {
skewProtection: 'proactive',
},
}),
],
}Options:
true(default): Detects chunk load failures and auto-reloads'proactive': Also polls for new versions and forces full-page navigation when a new deployment is detectedfalse: Disable skew protection entirely
See the Skew Protection guide for details.
web.suspendRoutes
Wrap each route screen in a React Suspense boundary on web. By default web does not wrap routes in Suspense, to avoid flickers during navigation. Enable this if your routes use React use() or lazy loading and you want Suspense boundaries per route.
Default: false
web.experimentalBundledDev
Serve the web client through Vite's bundled dev mode (Rolldown-powered full bundle dev environment) instead of unbundled ESM dev. This means fewer requests and faster parsing, especially for apps with large icon or utility imports. This is dev-only and is ignored for builds.
export default {
plugins: [
one({
web: {
experimentalBundledDev: true,
},
}),
],
}Default: false
react
react.compiler
Enables the new React Compiler for both native and web. Will slow down compilation as it uses Babel, but will improve app performance and reduce the need to write memoization.
export default {
plugins: [
one({
react: {
compiler: true, // enable for all platforms
// or
compiler: 'web', // enable only for web
// or
compiler: 'native', // enable only for native
// or
compiler: /\/app\//, // enable for files matching regex
// or
compiler: (id, env) => {
// env is 'ios' | 'android' | 'client' | 'ssr'
return id.includes('/components/')
},
// or configure per-platform
compiler: {
web: true,
native: /\/components\//,
},
},
}),
],
}Options:
true: Enable for all platforms (excluding node_modules, +api files)'web': Enable only for web (client and SSR)'native': Enable only for native (iOS and Android)RegExp: Enable for files matching the pattern (full control, no default filtering)(id, env) => boolean: Custom function with file path and environment ('ios' | 'android' | 'client' | 'ssr'){ web, native }: Configure each platform separately with any of the above options
When using RegExp or a function, you have full control over which files are compiled - the default exclusions (node_modules, +api files) are not applied. When using true (or true within the object form), the default filters apply.
Default: false
optimization
Performance optimization options.
optimization.autoEntriesScanning
By default, One scans your file system routes and adds them as Vite entries. This prevents some hard reloads on web as you navigate to new pages, but can slow down startup.
export default {
plugins: [
one({
optimization: {
autoEntriesScanning: 'flat', // only root-level routes (default)
// or
autoEntriesScanning: true, // scan all routes
// or
autoEntriesScanning: false, // disable scanning
},
}),
],
}Options:
'flat'(default): Automatically adds just the routes at the root of your app but nothing nested in non-group folders below thattrue: Scans and adds all routes as entriesfalse: Disables auto-scanning entirely
autoWarmPlugin
A standalone Vite plugin that passively tracks dependencies Vite discovers during normal development. On your next dev server startup, those deps are pre-included in optimizeDeps.include, eliminating "optimized dependencies changed. reloading" cascades.
Import it from one/vite and add it as a top-level plugin:
import { autoWarmPlugin, one } from 'one/vite'
export default {
plugins: [
autoWarmPlugin(),
one({ ... }),
],
}During the first 5 minutes of each dev session, the plugin polls Vite's in-memory optimizer metadata and caches any newly discovered deps to node_modules/.vite/one-warm-deps.json. This file clears automatically when Vite clears its cache (lockfile or config changes).
To persist the cache permanently (surviving node_modules reinstalls), pass a file path:
autoWarmPlugin('./src/dev/warmDeps.json')devtools
Development tools to help debug and understand your app. Enabled by default in development mode.
export default {
plugins: [
one({
// Enable all devtools (default)
devtools: true,
// Disable all devtools
devtools: false,
// Configure individual tools
devtools: {
inspector: true, // enabled by default
editor: 'cursor', // editor CLI for the inspector, or set LAUNCH_EDITOR
seoPreview: true, // enabled by default
},
}),
],
}devtools.inspector
The source inspector lets you quickly find the source code for any element on the page:
- Hold Option (Mac) or Alt (Windows/Linux) for 0.8 seconds
- Hover over any element to see its source file location
- Click to open the file in your editor
This works automatically without any additional setup - One injects the necessary data attributes during development and handles opening files in your configured editor.
devtools.editor
Editor CLI command for the source inspector to open files with. Examples: 'cursor', 'code', 'zed', 'subl', 'webstorm'. You can also set it with the LAUNCH_EDITOR or EDITOR environment variable.
export default {
plugins: [
one({
devtools: {
editor: 'cursor',
},
}),
],
}devtools.seoPreview
The SEO Preview panel helps you visualize how your pages will appear in search results and social media shares:
- Press Alt+S to toggle the panel
- View previews for:
- Google Search - How your page appears in search results
- Open Graph - How your page appears when shared on Facebook, LinkedIn, etc.
- Twitter Card - How your page appears when shared on Twitter/X
- See all meta tags at a glance with validation warnings for common SEO issues
The panel automatically updates as you navigate or modify meta tags, making it easy to verify your SEO configuration during development.
devtools.plugins
Browser plugin modules to load into the One devtools overlay. Each entry is a module specifier or a path relative to the project root. The module default exports a OneDevtoolsPlugin, created with defineDevtoolsPlugin from one/devtools.
export default {
plugins: [
one({
devtools: {
plugins: ['./devtools/runtime-inspector.ts'],
},
}),
],
}See Dev Tools for the plugin shape.
devtools.rozenite
React Native DevTools plugins hosted through Rozenite. One discovers installed Rozenite packages from your dependencies and serves their panels on the native dev server, so j and dt show them without a Metro wrapper.
export default {
plugins: [
one({
devtools: {
rozenite: {
include: ['@rozenite/storage-plugin'],
exclude: ['@rozenite/network-activity-plugin'],
pluginDisplay: 'sidebar', // or 'tabs'
},
},
}),
],
}Set rozenite: false to keep the stock React Native DevTools frontend. When no plugin is installed, One does not mount the Rozenite host.
devtools.routeDebug
The Route Debug panel helps you understand your current route state during development:
- Press Alt+R to toggle the panel
- View information about the current route:
- Pathname - The current URL path
- Segments - Breadcrumb-style view of path segments
- Route Params - Dynamic route parameters (e.g.,
[slug],[id]) - Search Params - Query string parameters
- Hash - URL fragment (if present)
- Full URL - Complete URL for reference
The panel updates automatically as you navigate, making it easy to debug routing issues and verify params are being parsed correctly.
devtools.loaderTiming
The Loader Timing Waterfall panel shows performance metrics for your route loaders:
- Press Alt+L to toggle the panel
- View timing data for each loader:
- Module Load Time (blue) - Time to download and parse the loader module
- Execution Time (green) - Time for the loader function to complete
- Total Time - End-to-end timing
- Source - Whether this was an initial load, refetch, or preload
- Visual waterfall shows relative timing of module load vs execution
- Error states are highlighted with red indicators
The panel helps identify slow loaders and understand where time is being spent - whether in module loading (suggesting code splitting issues) or execution (suggesting slow data fetching).
devtools.routePreload
The Route Preload panel shows which routes have been preloaded during navigation:
- Press Alt+P to toggle the panel
- View preloaded routes with status:
- Loading - Preload in progress
- Loaded - Successfully preloaded (shows duration, loader/CSS status)
- Error - Preload failed (shows error message)
Note: Route preloading only works in production builds. In development, the panel shows a notice explaining this. Run yarn build && yarn serve to test preloading behavior.
Preloading is automatically triggered when users hover over links, warming the cache for likely navigation targets.
devtools.errorPanel
The Error Panel shows errors caught by error boundaries and loaders during development:
- Press Alt+E to toggle the panel
- View error history with:
- Error Type - Whether from render, loader, or hydration
- Route - The route where the error occurred
- Error Message - The error message with expandable stack trace
- Timestamp - When the error occurred
The panel automatically appears when the first error is caught, making it easy to spot issues immediately. Click "Clear" to reset the error history. Error boundaries and loaders dispatch events that the panel listens to, giving you a unified view of all errors in your app.
patches
While building One we found ourselves needing to apply small patches to a variety of packages. Packages in the React Native ecosystem are often published only for Metro, and so have a variety of weird setups.
We built an internal Vite plugin to easily apply patches across any package manager, but after installing One into a few larger production projects, we found ourselves wanting to have access to it.
The patches option lets you coerce your node_modules to be compatible with Vite.
Here's an example:
import type { UserConfig } from 'vite'
import { one } from 'one/vite'
export default {
plugins: [
one({
patches: {
// passing an object lets you easily apply patches to your actual node_modules
'@sentry/react-native': {
// you can specify a semver range to only apply to certain versions:
version: '~15.2.0',
// you can use globs to apply to only specific files
// if you pass an array value, One applies those transforms to matching files:
// swc => @swc/core will transform your file into CommonJS compatible code, removing JSX as well
// flow => the FlowType transformer will run to remove Flow types
// jsx => transforms JSX only
'**/*.js': ['flow', 'swc'],
// you can also apply custom transforms using a function
'dist/js/utils/environment.js': (contents) => {
if (typeof contents !== 'string') return
return contents.replace(
`import { version as RNV } from 'react-native/Libraries/Core/ReactNativeVersion';`,
`import { Platform } from 'react-native';\nconst RNV = Platform.constants.reactNativeVersion;\n`
)
},
},
},
})
]
} satisfies UserConfigAny transforms that are applied to specific files in node_modules will only be applied once. The original files will be stored alongside the transformed ones.
router
The router object allows you to configure routing behavior.
router.root
Dangerously customize the router root directory. This may lead to unexpected behavior. Prefer the default app directory, or pass --appDir to one generate-routes when your routes live elsewhere.
router.ignoredRouteFiles
An array of globs that One uses to ignore files in your routes directory. If a file matches any pattern, it will not be treated as a route.
This is particularly useful for ignoring test files, stories, or other files you want to colocate with your routes but don't want to be treated as actual routes.
export default {
plugins: [
one({
router: {
ignoredRouteFiles: [
'**/*.test.*',
'**/*.spec.*',
'**/*.stories.*',
'**/*.utilities.*',
],
},
}),
],
}Limitations: Currently, we only support patterns starting with **/*.
router.experimental.typedRoutesGeneration
Auto-generate route type helpers in your route files. Route types are always generated in app/routes.d.ts, but this option controls whether One automatically inserts type helpers into your route files.
export default {
plugins: [
one({
router: {
experimental: {
typedRoutesGeneration: 'runtime', // or 'type' or false
},
},
}),
],
}Options:
false(default): No auto-generation, manually add types yourself'type': Auto-inserts type-only helpers:
import type { RouteType } from 'one'
type Route = RouteType<'/your/[route]'>'runtime': Auto-inserts runtime helpers:
import { createRoute } from 'one'
const route = createRoute<'/your/[route]'>()The insertion happens automatically when route files are created or modified, with proper spacing and without modifying your existing code.
For more information, see the Typed Routes documentation.
build
Build-time options. See one build for the full build documentation.
build.concurrency
Maximum number of static pages to build concurrently. The default of two keeps peak memory low; raise it on a machine with memory to spare. ONE_BUILD_CONCURRENCY overrides this value.
export default {
plugins: [
one({
build: {
concurrency: 4,
},
}),
],
}Default: the smaller of your CPU core count and 2
build.workers
Use worker threads for parallel static page generation during build. This utilizes multiple CPU cores for faster builds. Set to false to disable, or use ONE_BUILD_WORKERS=0 for a single run (=1 forces them on).
Default: true
build.server / build.api
build.server accepts VXRN server build options, or false. build.api accepts VXRN build options for API routes.
build.securityScan
Scan client bundles for leaked secrets (API keys, tokens, passwords) after building. This catches accidental exposure of server-side secrets in client-facing JavaScript before they reach production.
export default {
plugins: [
one({
build: {
// Log warnings when secrets are found (default)
securityScan: 'warn',
// Fail the build if secrets are found (recommended for production)
securityScan: 'error',
// Disable scanning entirely
securityScan: false,
// Object form for fine-grained control
securityScan: {
level: 'error',
safePatterns: [
// exact strings that are known safe
'09259e3b-7be8-46f6-9801-106bf1866e1c',
// regex patterns for domain-specific matches
/my-app-test-token-[a-z]+/,
],
},
},
}),
],
}Options:
'warn'(default): Log warnings but don't fail the build'error': Fail the build if secrets are detected — recommended for production CI/CDfalse: Disable scanning entirely{ level, safePatterns }: Object form withlevel('warn'or'error') andsafePatterns(array of strings or RegExp to ignore)
The scanner checks for common secret patterns including Anthropic, OpenAI, Stripe, GitHub, and AWS keys, as well as generic secret/password/token assignments. Known safe patterns (WebRTC constants, CSS tokens) are automatically excluded. Use safePatterns to add your own.
server
Production server options, passed through to the underlying server. See one serve for the CLI side.
server.loggingEnabled
Log HTTP requests to the console.
Default: true
skipEnv
Skip loading .env files during build. The same effect for a single run comes from the one build --skip-env flag, which takes precedence when passed.
export default {
plugins: [
one({
skipEnv: true,
}),
],
}ssr
Server-side rendering configuration.
ssr.autoDepsOptimization
One scans dependencies on startup and decides which ones to optimize based on known problematic dependencies. These include react-native and react, which need to be optimized to work. It finds all parent dependencies of the known bad deps and adds them to ssr.optimizeDeps.include.
export default {
plugins: [
one({
ssr: {
// Enable with default include pattern (default)
autoDepsOptimization: true,
// Disable entirely
autoDepsOptimization: false,
// Configure include/exclude patterns
autoDepsOptimization: {
include: /node_modules/,
exclude: /some-package/,
},
},
}),
],
}Note: The full path (e.g., <your_project_path>/node_modules/<some_package>) is used to match dependencies. If you're using a string to match a package name, you may want to add */ at the start and /* at the end.
Default: { include: /node_modules/ }
ssr.dedupeSymlinkedModules
In monorepos with symlinked workspace packages, Vite's SSR optimizer registers pre-bundled deps by their node_modules path, but the resolver follows symlinks to the real source path. This creates duplicate module instances: the optimizer serves one copy while source imports create another.
Enable this to redirect symlink-resolved paths back to node_modules paths so the optimizer can recognize and deduplicate them.
export default {
plugins: [
one({
ssr: {
dedupeSymlinkedModules: true,
},
}),
],
}Default: false
environmentGuards
Configure environment guard behavior for server-only, client-only, native-only, and web-only imports.
One enforces these guard imports to prevent modules from being used in the wrong build environment. However, some libraries (like floating-ui) import client-only even though the imported code works fine on the server when you're only importing utilities.
export default {
plugins: [
one({
// Disable the client-only guard (allows importing on server)
environmentGuards: {
disableGuards: ['client-only'],
},
// Or disable all environment guards entirely
environmentGuards: {
disabled: true,
},
}),
],
}Options:
disabled: Whentrue, disables all environment guard checks. All guard imports become no-ops regardless of environment.disableGuards: An array of specific guard types to disable. Options are'server-only','client-only','native-only', and'web-only'.
Default: All guards are enabled.
transform
Per-file control over how code transforms. One uses OXC for worklets and React Compiler. Reanimated callbacks, gesture callbacks and explicit worklets do not require a Babel plugin. Babel still runs for configured Babel transforms and syntax that needs it.
Pass a function that receives the file id, its code, and the build context, and returns how to transform it:
export default {
plugins: [
one({
transform: ({ id }) => {
if (id.includes('node_modules')) return false
return true
},
}),
],
}The function receives { id, code, development, environment, reactForRNVersion }, where environment is 'ios' | 'android' | 'client' | 'ssr'. It returns:
true: default transformsfalse: no transforms'babel': force the Babel default transform'swc': force the SWC default transform'oxc': force the OXC default transform{ transform: 'babel', ... }: force Babel with custom options{ transform: 'swc' | 'oxc', ... }: force SWC or OXC with custom options
Babel defaults to preset @babel/preset-typescript with plugins for destructuring, runtime helpers, JSX, and async functions. SWC targets es5 for native and es2020 for web.
The Environment API
One runs on Vite 8 and makes use of its Environment API.
This makes it easier to target React Native, as you can specify configuration for iOS, Android and web separately. One defines ios and android environments, respectively. For web, we follow the Vite standard of having a client and ssr environments (for client-side and server-side bundles).
You'll want to lean on using the Environment API to configure as much as you can in your vite.config.ts. It may also mean that some plugins can cause issues by configuring things across all four environments when you really only want it for client-side, or not. We're working on some helpers to make this easier, but in general you can check for this.environment.name in plugin hooks (like transform and load) to conditionally exit early from their logic for environments where you don't want the plugin logic to run.
One assumes the following environments:
client: Web client-side (matching Vite default)ssr: Web server-side (matching Vite default)ios: iOSandroid: Android
We set up platform-specific extensions based on the environment:
- client:
.web.(js|ts|tsx|mjs) - ssr:
.server.(ts|tsx),.web.(js|ts|tsx|mjs) - ios:
.(ios|native).(js|ts|tsx|mjs) - android:
.(android|native).(js|ts|tsx|mjs)
Server-only files
Files with a .server.ts or .server.tsx extension are resolved only in the ssr environment. This lets you colocate server-only code next to shared code:
src/
db.ts # shared types/interface
db.server.ts # server-only implementation (database queries, secrets, etc.)When you import './db' from a server-rendered route or loader, it resolves to db.server.ts. On the client or native, it resolves to db.ts.
If a .server.ts file is explicitly imported on the client or native (e.g. import './db.server'), the build will throw an error to prevent server code from leaking into client bundles.
Other Exports
The one/vite import has a few other exports that may be useful.
`resolvePath`
When you are setting an alias in Vite, it wants you to fully specify the import path. We use resolvePath to help with this, it's a bit like require.resolve that works in ESM or CJS. It's also similar to import.meta.resolve, except it returns an absolute path that Vite expects rather than a file path.
Default Configuration
One sets up some default configuration out of the box, including a variety of plugins and settings that make React Native and the ecosystem of packages that are popular "just work".
Some base configuration we set:
publicDirto"public"clearScreentofalseso we can output the quick actions
We set up defines:
__DEV__to true in development mode, or false otherwiseprocess.env.NODE_ENV
We set up alias based on environment:
react-native=>react-native-webfor web environments
We also detect if you have a PostCSS config file (js, ts, or json) and set the css. The internal logic is something like:
{
css: postCSSConfigPath
? {
postcss: postCSSConfigPath,
}
: {
transformer: 'lightningcss',
lightningcss: {
targets: {
safari: (15 << 16) | (2 << 8),
},
},
}
}Apps Onestack.Dev Data Docs Dev Tools (`apps/onestack.dev/data/docs/dev-tools.mdx`)
title: Dev Tools
One has two development tool surfaces:
- A browser panel for web apps, with SEO, route, loader, error, and source inspection panels.
- React Native DevTools for iOS and Android apps, where One serves the stock debugger and can host Rozenite plugins.
Both work in development with no setup. You can add your own panels to the web overlay, and Rozenite plugin packages automatically appear in React Native DevTools.
Opening Dev Tools
For the web overlay, press Alt+Space (Option+Space on Mac) to open the spotlight menu, then select a tool. You can also use keyboard shortcuts to jump directly to a specific panel:
- Alt+S - SEO Preview
- Alt+R - Route Info
- Alt+L - Loader Timing
- Alt+E - Errors
Press Escape to close any panel.
For React Native, press j or dt in the one dev terminal. That opens React Native DevTools for the connected Hermes app, with React DevTools, the JavaScript console, sources, network, and memory panels.
React Native DevTools attaches to one app at a time. If another debugger or a CDP client is attached, close it before opening DevTools again.
SEO Preview
See how your page appears in Google search results and check for common SEO issues:
- Google Preview - Live preview of your search result appearance
- Meta Tags - View title, description, and Open Graph tags
- Issues - Warnings for missing or too-long meta tags
Route Info
Debug the current route state:
- Pathname - The current URL path
- Segments - Path broken into segments
- Route Params - Dynamic params from the route (e.g.,
[id]) - Search Params - Query string values
Loader Timing
Visualize loader performance with a waterfall breakdown:
- Module Load Time - Time to import the loader module
- Execution Time - Time to run the loader function
- Total Time - Combined timing with visual bar
Useful for identifying slow loaders and optimizing data fetching.
Errors
Track errors from error boundaries and loaders:
- Error Type - Whether from a boundary or loader
- Timestamp - When the error occurred
- Message - The error message
Errors are collected in real-time so you can see issues as they happen.
Source Inspector
The inspector is injected automatically in development when devtools.inspector is enabled (the default). It works through the data-one-source attribute One adds to components during development. Hold Option (Mac) or Alt (Windows/Linux) for 0.8 seconds and hover over any element to see its source location highlighted. Click to open a picker showing:
- Quick actions:
- Open - Open topmost component in your editor
- Path - Copy all source paths to clipboard
- Selector - Copy a smart CSS selector (prefers IDs)
- Record - Start recording user input (see below)
- Parent list - Click any parent component to open that file
Files open in the editor configured via devtools.editor, or the LAUNCH_EDITOR / EDITOR environment variable.
SourceInspector component
one also exports a SourceInspector component with its props type, kept for backwards compatibility:
import { SourceInspector } from 'one'
// deprecated: the inspector above is injected automatically.
// this component renders nothing. remove it for cleaner code.
export function Layout() {
return <SourceInspector />
}SourceInspectorProps accepts hotkey and enabled, but both are deprecated: the activation key and enabled state now come from the injected devtools script and the devtools.inspector config.
Input Recording
Record user interactions for bug reports or AI-assisted debugging. Start recording from:
- The Record button in the Source Inspector picker
- Record Input in the Alt+Space spotlight menu
After a 2-second countdown, recording begins. Tap Option to stop and copy the log to clipboard.
The output is a compact, line-based format optimized for AI consumption:
# Recording 2024-03-02T12:30:00.000Z
# https://localhost:3000/dashboard
# components: Button < Card < Dashboard
# selector: [data-testid="submit-btn"]
# window: 1920x1080 scroll:0,0
# duration: 3200ms events: 28
0 click 450,320 btn:0 [#submit] @Button.tsx:15 "Submit"
85 keydown Tab
142 focus [input.email]
200 input val:"test@" [input.email]
# final hover: [.sidebar > nav]
# final source: Nav.tsx:42:5Events captured include clicks, keyboard input, scroll, focus/blur, input changes, wheel, resize, and more.
Panel Features
The dev tools panel is:
- Draggable - Drag the header to reposition
- Edge Snapping - Snaps to screen edges with 10px padding
- Resizable - Tracks window resize when snapped to edges
Extending the web panel
A plugin is a module that default exports a OneDevtoolsPlugin. Use the defineDevtoolsPlugin helper so the editor checks the shape:
// devtools/runtime-inspector.ts
import { defineDevtoolsPlugin } from 'one/devtools'
export default defineDevtoolsPlugin({
name: 'runtime-inspector',
title: 'Runtime Inspector',
category: 'Tuning',
// An iframe keeps a devtool that already runs its own UI and server as-is.
panel: {
url: 'http://localhost:4577',
},
actions: [
{
id: 'open-web-panel',
title: 'Open Runtime Inspector',
run() {
window.open('http://localhost:4577', '_blank')
},
},
],
})Register it in vite.config.ts:
import { one } from 'one/vite'
export default {
plugins: [
one({
devtools: {
plugins: ['./devtools/runtime-inspector.ts'],
},
}),
],
}Plugin modules run in the browser. They can add a panel, actions, or both:
panel.urlloads an existing devtools page in an iframe. Use this for tools that already run their own frontend and server.panel.mount(root, context)renders a panel directly into the overlay. Return a cleanup function to remove listeners when the panel closes.actionsappear in the Alt+Space spotlight and receive the same context as panels.
The context exposes project, url, openPanel, and closePanel. Panel ids are namespaced as plugin:<name>. Two plugins cannot claim the same name.
Plugins are loaded only in development. The module is resolved through Vite, so local TypeScript and JSX files work without a build step.
Rozenite plugins in React Native DevTools
One hosts the Rozenite plugin convention for React Native. If a Rozenite plugin package is installed in your app, One mounts its plugin host on the native dev server and React Native DevTools shows the panels. Existing Rozenite plugins work without a Metro wrapper or a separate withRozenite config.
bun add -d @rozenite/storage-pluginOn the next one dev, pressing j or dt opens DevTools with the Storage panel. One logs the plugin names it found:
[one] React Native DevTools plugins: @rozenite/storage-plugin (open with dt or j)Discovery checks direct dependencies for a Rozenite manifest (dist/rozenite.json), the same convention Rozenite's Metro integration uses. To pin the list or exclude a plugin:
one({
devtools: {
rozenite: {
include: ['@rozenite/storage-plugin'],
exclude: ['@rozenite/network-activity-plugin'],
pluginDisplay: 'sidebar', // or 'tabs'
},
},
})Set rozenite: false to keep the stock React Native DevTools without the Rozenite host. When no Rozenite plugin is installed, One leaves the debugger frontend untouched.
One hosts Rozenite when One's Vite bundler serves the native app, which is the default. If you set native.bundler: 'metro', keep using Rozenite's own withRozenite Metro wrapper instead.
The device half of a Rozenite plugin still lives in your app. Follow the plugin's install instructions and import it once from your app entry when it asks you to.
Runtime Inspector
Runtime Inspector is the reference adapter for this API. It already ships its semantics and layouts as a protocol-first package with a Rozenite panel. In a One app:
- Add
@runtime-inspector/react-nativeand@runtime-inspector/panel-rozeniteto the app. - Import the panel once from the app entry:
import '@runtime-inspector/panel-rozenite'. - Press j or dt. Runtime Inspector appears in React Native DevTools through Rozenite.
Its web panel already runs as its own server, so the web adapter is a plugin with panel: { url } like the example above. No Runtime Inspector protocol change is needed for either host.
Configuration
Dev tools are enabled by default in development mode. To disable them, set:
// vite.config.ts
import { one } from 'one/vite'
export default {
plugins: [
one({
devtools: false,
}),
],
}To keep the web tools but change individual features:
one({
devtools: {
inspector: true,
editor: 'cursor',
seoPreview: true,
// browser plugin modules for the One overlay
plugins: ['./devtools/my-panel.ts'],
// Rozenite host for React Native DevTools
rozenite: {
pluginDisplay: 'tabs',
},
},
})Apps Onestack.Dev Data Docs Environment (`apps/onestack.dev/data/docs/environment.mdx`)
title: Environment
Variables
Loading .env files and process.env
One handles environment variables for you in a few ways.
First, it handles loading .env files during development and in production. In order to ease compatibility with ESM and CJS, One makes your loaded environment variables (from .env and from process.env) automatically be defined onto both import.meta.env and process.env.
Second, One ensures that all environment variables loaded are only available to the server-side functionality, unless they are prefixed with ONE_PUBLIC_ or VITE_ (Vite convention).
So, for example ONE_PUBLIC_DAYTIME=false one dev would set both process.env.ONE_PUBLIC_DAYTIME and import.meta.env.ONE_PUBLIC_DAYTIME to be the string "false" on both server and client.
ONE_PUBLIC_ is One's naming convention. EXPO_PUBLIC_ is also accepted; see Coming from Expo.
But, SOME_RANDOM_VAR=2 would not be exposed to the client side, because it lacks the appropriate prefix.
Preset environment variables
One also sets some environment variables for you, available on both process.env and import.meta.env.
Server
ONE_PORTsets the port for bothone devandone serve. This is equivalent to passing--porton the command line, but works across both commands and can be set in your.envfile or deployment config. If--portis also passed, the CLI flag takes precedence.ONE_FORCE_PORToverrides the port for bothone devandone serve, taking precedence over--portandONE_PORT. Useful for CI or deployment environments where the port must be fixed regardless of other configuration.
Port priority (highest wins):
ONE_FORCE_PORT--portCLI flagONE_PORT- Built-in defaults (8081 for dev, 3000 for serve)
Core Variables
ONE_CACHE_KEYis set to a random number for each production build, or stable per dev server run. Useful for cache keys.ONE_APP_NAMEis set to yournative.app.namesetting, falling back tonative.key.ONE_SERVER_URLis set in development mode to the current running server URL, likehttp://0.0.0.0:8081. You are expected to set this yourself for production builds.ONE_DEFAULT_RENDER_MODEis set to either"ssr","ssg", or"spa"based on your defaultRenderMode setting.ONE_RENDER_MODEis the render mode of the current page being rendered ("ssr","ssg", or"spa"). Set before the setup file runs so you can conditionally skip setup logic for certain modes.
Platform Detection
VITE_ENVIRONMENTis set to"client"for client-side web,"ssr"for server-side web, or"ios"/"android"for native platforms.VITE_NATIVEis"1"for native platforms (iOS and Android), or""for web (client and SSR). Uses truthy/falsy strings to avoid pollutingprocess.envtypes. Useful for tree-shaking native-only code.ONE_PLATFORMis"web","ios", or"android".TAMAGUI_TARGETis"web"for web builds or"native"for native builds. Used by Tamagui and One native route branches.
React Native
Available in native builds:
REACT_NATIVE_VERSIONcontains the React Native version string.REACT_NATIVE_PLATFORMis set to"ios"or"android"in native builds.REACT_NATIVE_SERVER_PUBLIC_PORTis the dev server port for React Native.
TypeScript Types
One provides TypeScript types for all preset environment variables. To enable them, add a triple-slash reference to your project:
/// <reference types="one/env" />You can add this to any .d.ts file in your project (such as env.d.ts or vite-env.d.ts), or include it in a file that's part of your TypeScript compilation.
This gives you autocomplete and type checking for import.meta.env.VITE_ENVIRONMENT, process.env.VITE_NATIVE, and all other One environment variables.
Apps Onestack.Dev Data Docs Exports WithLayoutContext (`apps/onestack.dev/data/docs/exports-withLayoutContext.mdx`)
title: withLayoutContext
Today there are a few built-in navigators: Slot, Stack, etc. But you may want to create your own.
A React Navigation navigator can become a One layout through withLayoutContext. For example, an app can wrap a native stack with shared route sorting and protected-route support:
To make any React Navigation navigator work inside a One layout, use withLayoutContext:
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { withLayoutContext } from 'one'
const ModalNavigator = createNativeStackNavigator().Navigator
export const ModalStack = withLayoutContext(
ModalNavigator
)Now you can use ModalStack in any _layout.tsx file.
Apps Onestack.Dev Data Docs Faq (`apps/onestack.dev/data/docs/faq.mdx`)
title: FAQ
Why Vite?
It feels light and simple, runs fast and is easy to customize. It tree shakes well, has tons of web features, and has best-in-class types, documentation, type-docs, RFCs and community. It also has a huge ecosystem and responsive team.
Is this production ready?
Yes! Thanks to many web bug fixes, tests and improvements, and One's own native bundler and modules, One is now generally production ready. See the Status documentation for more details.
Does this use Metro?
No. One bundles native apps with Rolldown, the same bundler that builds web, so both platforms share one pipeline. Metro mode still exists as an opt-in.
How does this relate to Expo Router?
One started as a fork of Expo Router but has since diverged significantly. We aim to maintain API compatibility where possible.
Why use One instead of Expo Router?
One runs on Vite, which is fast, light, and has a massive ecosystem with best-in-class TypeScript support. One achieves significantly higher Lighthouse scores thanks to render modes, optimized loading strategies, and proper code splitting - you can hit 100 on Lighthouse while sharing nearly all your code with native.
With render modes, you can choose SSG, SSR, or SPA on a per-page basis - Expo Router is SPA-only on web. One includes a Hono-based production server out of the box and deploys to Vercel or Cloudflare with a single config option.
One also adds dev tools (Alt+Space) with SEO preview, route debugging, loader timing, error tracking, and a source inspector. Plus middleware for request interception and auth, and typed loaders that run on the server and tree-shake out of client bundles.
Expo Router does have features One lacks, like use dom and RSC support. We encourage you to evaluate both and choose based on your needs.
Will you support RSC?
No, we find the complexity trade-off not worth it. One supports SSG, SPA, and SSR with loaders, and we recommend pairing with a sync engine like Zero for data. A simplified one-way RSC could work, which we'll continue to evaluate.
What platforms does One support?
macOS (stable), Linux (stable), and Windows (early, not officially supported). See the Status documentation for details.
Can I use One for web-only or native-only projects?
Yes. One works great as a web-only framework - you don't even need react-native as a dependency. For native-only, you can skip web-specific features like SSG/SSR. One is designed to be flexible whether you're targeting one platform or both.
How does One compare to Next.js?
One takes a simpler approach: no RSC, no complex server/client boundaries. Instead, One uses loaders for data fetching (similar to Remix) and recommends pairing with a sync engine like Zero for real-time data. One also targets React Native alongside web with a single codebase, which Next.js doesn't support.
That said, One provides many things out of the box: image optimization with blur placeholders, dynamic OpenGraph images, middleware, automatic sitemaps, dev tools, a production Hono server, API routes, typed routes, scroll restoration, and a loading progress bar. See Features for the full list.
How does One compare to TanStack Start?
Both are modern Vite-based frameworks with type safety and flexible rendering. One uses file suffixes (page+ssg.tsx, page+ssr.tsx) to set render modes while TanStack uses route property configuration. One also supports a global defaultRenderMode.
In One, SSG routes run loaders at build time and output static HTML. TanStack's "static prerendering" is a post-build crawl - a different model. One includes a Hono server out of the box while TanStack Start requires setting up Nitro or deploying to specific platforms. One's loaders run on the server and tree-shake out of client bundles, while TanStack's loaders are isomorphic (run on server, then again on client during navigation). And One targets web and native with shared routes and loaders - TanStack Start is web-only.
Both are great choices for web - One is the better fit if you need cross-platform or prefer the simpler file-based approach.
What's the difference between SSG, SSR, and SPA?
SSG (Static Site Generation) pre-renders pages at build time - fast and cacheable, ideal for mostly-static content. SSR (Server Side Rendering) renders on each request - good for dynamic content that needs SEO. SPA (Single Page Application) renders entirely on the client - simpler for highly dynamic dashboards. See Render Modes for details.
Can I use my existing React Native libraries?
Yes. For libraries that have issues with Vite, One provides a patches system to fix common problems like Flow types or JSX in .js files.
How do I deploy a One app?
One includes a Hono server out of the box - just run one build && one serve. You can also deploy to Vercel or Cloudflare with a single config option.
What's the npm package?
It's one, thanks to Dan Maier.
Can I develop iOS apps on Windows or Linux?
Yes. With EAS Build, you can build iOS apps in the cloud from any platform. For local development, you can test on Android and web, then build iOS when ready. See Build or Run iOS for details.
What's the bundle size overhead?
One itself adds some overhead, but not enough to affect Lighthouse scores in general.
The core routing and rendering logic is under 20KB gzipped, but it brings in React Navigation and generally react-native-web. You can swap for @tamagui/react-native-web-lite using an alias to save quite a bit of bundle size.
Can I use One with an existing React Native app?
Yes. One can be added incrementally to existing React Native projects. Start by installing one and setting up file-based routing alongside your existing navigation.
Does One support monorepos?
Yes. One works well in monorepos with tools like Turborepo, Nx, or pnpm workspaces. Configure your workspace's package resolution and One will respect it.
Apps Onestack.Dev Data Docs Features (`apps/onestack.dev/data/docs/features.mdx`)
title: Features
See the Status document for the status of features.
Vite
The One Vite Plugin automates away much of the setup & boilerplate required for cross-platform React Native & web development.
Out of the box, the One Vite plugin does the following:
- Sets up a file system router in the
./appfolder - Generates types for routes and updates them when routes change
- Enables loaders and tree shakes them out on client using babel-dead-code-elimination
- Supports loader HMR with
watchFilefor instant content updates during development - Enables the
patchesoption that allows for easy node_module patching - Uses React 19 for both native and web
- Adds vite-tsconfig-paths plugin, unless it detects you've added it already
- Loads
.envfile intoprocess.envbefore running anything using dotenv - Adds a well-configured
tsconfig.jsonif it doesn't exist - Enables
?imagedataimports for automatic image dimensions and blur placeholders - Supports
.inline.cssfiles to inline critical CSS and prioritize layout CSS before scripts - Configures native to resolve "react-native" export field in package.json
- Enforces environment guard imports (
server-only,client-only,native-only,web-only) that prevent modules from being used in the wrong build environment - Configures platform-specific extensions
web|server|native|ios|androidfor extensionsts|tsx|js|mjs, eg.ios.ts,.server.ts - Adds a ssr-css plugin that enables perfect dev-mode CSS on first load, matching production
- Configures a wide variety of optimizeDeps presets for React Native library compatibility
- Patches a large number of known-bad node_modules to work with Vite
- Ensures optimized deps across all routes are found before first load, avoiding hard reloads and slow loads in development mode
Universal
- Uses React Navigation routing and truly native layouts
- Adds loaders that work on native and web
- Adds layout loaders - layouts can export
loader()just like pages - Adds
useMatcheshook to access all matched route data - Adds
useMatchto find a specific route's loader data by route ID - Adds
usePageMatchto get the current page's match - Adds a Link component for navigation
- Adds
useSearchParamsfor URLSearchParams API - Adds a wide variety of hooks for routing
- Exposes One.UI.SafeArea with first-party native and web implementations
- Adds `` component for declarative route guarding
- Adds
useBlockerhook for blocking navigation with confirmation - Adds route validation with
validateParamsandvalidateRoute - Adds
loaderCachefor deduplicating concurrent SSR loader calls with optional TTL
Native
- Implements a custom bundle output with React Native compatible commonjs
- Implements custom hot reloading that works with commonjs
- Implements a custom bridge to React Native for debug logging
- Implements logic to support importing assets, like images
- Tree-shakes server code from native bundles automatically
- Auto-detects and configures NativeWind support
- Auto-detects and configures Reanimated support
- Stack Header Composition API for declarative native header configuration
Web
- Adds
servecommand that sets up a Hono server for production with optional cluster mode for multi-core scaling - Implements SPA, SSG, and SSR style routing on a per-page and global basis
- Server rendering - find JS and CSS for all nested routes and layouts and hoists it to header to avoid waterfalls and flickering
- Smart link prefetching that predicts navigation using mouse trajectory
- Doesn't require up-front route map so no scaling cost
- Automatically handles scroll restoration and allows configuration
- Adds scroll position groups for independent scroll areas
- Adds route masks for displaying different URLs
- Adds an easy
redirectsoption - Adds
generateStaticParamsexport for SSG and SSR routes - Implements API routes
- Adds a page loading progress bar
- Swaps
query-stringandurl-parselibraries to save ~20Kb - Adds a Redirect component
- Supports middleware for request interception and shared context
- Deploy to Vercel or Cloudflare workers with a single config option
- Automatic sitemap generation with per-route overrides
- SPA shell rendering - render the root layout into HTML for SPA pages so users see nav, head tags, and CSS immediately while page content loads client-side
Devtools
- Dev Tools Panel - Press Alt+Space to open the spotlight menu with SEO Preview, Route Info, Loader Timing, and Errors panels
- Source Inspector - Hold Option (Mac) or Alt (Windows/Linux) and click any element to open in your editor
Build
- Parallel builds with worker threads for ~40% faster static page generation
Extras
- Exports a variety of helpful utility functions
- Guide for MDX support
- Guide for light and dark mode
- Guide for optimized images with
?imagedataimports
Apps Onestack.Dev Data Docs Guides Authentication (`apps/onestack.dev/data/docs/guides-authentication.mdx`)
title: Authentication
This guide covers authentication patterns for One apps, from simple session management to full OAuth flows. One's unique combination of middleware, loaders, and cross-platform support gives you flexibility in how you protect routes.
Approaches
There are three main approaches to authentication:
| Approach | Best For | Complexity |
|---|---|---|
| Self-hosted libraries | Most apps, full control, own database | Low-Medium |
| Hosted providers | Fastest setup, managed infrastructure | Low |
| Custom auth | Specific requirements, full control | High |
Self-hosted libraries like Better Auth or Auth.js run on your own server with your own database. You get full control over user data while the library handles the complexity of OAuth flows, session management, and security best practices. This is our recommended approach.
Hosted providers like Clerk or Supabase Auth manage everything for you including infrastructure. Fastest to set up but user data lives on their servers.
Custom auth means implementing everything yourself - password hashing, session management, token validation, OAuth flows. Only recommended if you have very specific requirements.
Session Context
The foundation of any auth system is a session context that tracks auth state across your app.
import { createContext, useContext, useState, useEffect, type PropsWithChildren } from 'react'
import { storage } from './storage'
type AuthContextType = {
signIn: (token: string) => void
signOut: () => void
session: string | null
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | null>(null)
export function useSession() {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useSession must be used within SessionProvider')
}
return ctx
}
export function SessionProvider({ children }: PropsWithChildren) {
const [session, setSession] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
storage.get('session').then((token) => {
setSession(token)
setIsLoading(false)
})
}, [])
const signIn = (token: string) => {
storage.set('session', token)
setSession(token)
}
const signOut = () => {
storage.remove('session')
setSession(null)
}
return (
<AuthContext.Provider value={{ signIn, signOut, session, isLoading }}>
{children}
</AuthContext.Provider>
)
}Cross-Platform Storage
For token storage, use a cross-platform storage library. Popular options:
- react-native-mmkv - Fast key-value storage (~30x faster than AsyncStorage), works on web and native
- Zustand with persist middleware - State management with built-in storage abstraction
- One.SecureStore - Keychain and Keystore storage for sensitive tokens (native only)
Create a platform-specific abstraction using One's file extensions:
features/auth/
├── storage.ts # Shared interface
├── storage.web.ts # localStorage or MMKV for web
└── storage.native.ts # MMKV or One.SecureStore for nativeThis keeps your auth context clean while each platform uses appropriate secure storage.
Protected Routes
There are two approaches to protecting routes in One:
| Approach | Best For |
|---|---|
| `` | Routes that shouldn't exist when unauthorized (hidden tabs, admin-only pages) |
| Redirect guard | Routes that redirect to login but support returning after auth |
Declarative with ``
The `` component completely filters out routes when its guard prop is false. This is ideal when routes should be invisible to unauthorized users:
import { Stack, Protected } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function AppLayout() {
const { session, isLoading } = useSession()
if (isLoading) return null
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Protected guard={!!session}>
<Stack.Screen name="dashboard" options={{ title: 'Dashboard' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Protected>
<Protected guard={session?.role === 'admin'}>
<Stack.Screen name="admin" options={{ title: 'Admin' }} />
</Protected>
</Stack>
)
}When session is null, attempts to navigate to /dashboard or /settings are blocked entirely - the routes don't exist in the navigation state.
Redirect Guard
Protect routes using a layout guard that checks auth state and redirects unauthorized users. This approach allows deep linking to protected pages - users are redirected to login, then can return to their original destination.
<RouteTree
routes={[
{ name: '_layout.tsx', description: 'Root layout with SessionProvider' },
{ name: 'login.tsx', description: 'Public - login page' },
{ name: '(app)', children: [
{ name: '_layout.tsx', description: 'Auth guard - redirects if not logged in' },
{ name: 'index.tsx', description: 'Protected home' },
{ name: 'profile.tsx', description: 'Protected profile' },
] },
]}
/>
import { Slot } from 'one'
import { SessionProvider } from '~/features/auth/ctx'
export default function RootLayout() {
return (
<SessionProvider>
<Slot />
</SessionProvider>
)
}import { Redirect, Slot, useFocusEffect, useRouter } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function AppLayout() {
const { session, isLoading } = useSession()
const router = useRouter()
// Re-check auth when screen focuses (important for native)
useFocusEffect(() => {
if (!isLoading && !session) {
router.replace('/login')
}
}, [isLoading, session])
if (isLoading) {
return null
}
if (!session) {
return <Redirect href="/login" />
}
return <Slot />
}The useFocusEffect ensures auth is re-checked when returning to a screen on native, where screens may stay mounted in memory.
Redirect Logged-In Users
Keep authenticated users away from auth pages:
import { Redirect, useRouter } from 'one'
import { useSession } from '~/features/auth/ctx'
export default function LoginPage() {
const { session } = useSession()
const router = useRouter()
if (session) {
return <Redirect href="/" />
}
const handleLogin = async () => {
// ... your login logic
router.replace('/')
}
return (
<Button onPress={handleLogin}>
Sign In
</Button>
)
}Middleware Authentication
One's middleware runs on the server before your route, making it ideal for auth checks.
Protect API Routes
import { createMiddleware } from 'one'
import { validateToken } from '~/features/auth/server'
export default createMiddleware(async ({ request, next, context }) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const user = await validateToken(token)
context.user = user
} catch {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
return await next()
})Loader Authentication
Check auth in loaders for SSR pages. Loaders run on the server, so redirect() works for both direct page loads and client-side <Link> navigation — the protected page never renders and no sensitive data reaches the client.
import { redirect, useLoader } from 'one'
import type { LoaderProps } from 'one'
import { validateSession } from '~/features/auth/server'
export async function loader({ request }: LoaderProps) {
const session = await validateSession(request)
if (!session) {
throw redirect('/login')
}
const user = await getUser(session.userId)
return { user }
}
export default function ProfilePage() {
const { user } = useLoader(loader)
return (
<View>
<Text>Welcome, {user.name}</Text>
</View>
)
}Both throw redirect() and return redirect() work — throw is often cleaner since it stops execution and makes it obvious the remaining code won't run.
Use +ssr routes for protected pages so loaders run on every request, including client-side navigation. This ensures auth is always checked server-side.
Note: Component JavaScript is always downloadable (same as every framework). Loaders protect data — use them to ensure sensitive data never reaches unauthorized clients.
OAuth Integration
GitHub OAuth Example
import { betterAuth } from 'better-auth'
import { jwt, bearer } from 'better-auth/plugins'
export const auth = betterAuth({
database: process.env.DATABASE_URL,
plugins: [
jwt({ expirationTime: '7d' }),
bearer(),
],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
})import { auth } from '~/features/auth/server'
export const GET = (req: Request) => auth.handler(req)
export const POST = (req: Request) => auth.handler(req)import { authClient } from '~/features/auth/client'
export default function LoginPage() {
const handleGitHubLogin = () => {
authClient.signIn.social({ provider: 'github' })
}
return (
<Button onPress={handleGitHubLogin}>
Sign in with GitHub
</Button>
)
}Logout
import { useRouter } from 'one'
import { useSession } from './ctx'
import { authClient } from './client'
export function useLogout() {
const router = useRouter()
const { signOut } = useSession()
const logout = async () => {
try {
await authClient.signOut()
signOut()
router.replace('/login')
} catch (error) {
console.error('Logout failed:', error)
}
}
return { logout }
}Related
- Protected - Declarative route protection
- Middlewares - Server-side request interception
- Loaders - Data loading with auth
- API Routes - Creating API endpoints
Apps Onestack.Dev Data Docs Guides Css (`apps/onestack.dev/data/docs/guides-css.mdx`)
title: CSS Loading
One automatically optimizes CSS loading in production builds. Layout CSS is loaded before scripts to prevent flash of unstyled content (FOUC), and you can mark individual CSS files as inline to embed them directly into the HTML.
CSS Loading Order
In production builds, One separates CSS into two categories:
- Layout CSS — CSS imported by
_layoutfiles. Loaded before any JavaScript to prevent FOUC. - Page CSS — CSS imported by page components. Loaded after scripts.
This happens automatically based on your route structure. No configuration needed.
Inline CSS (`.inline.css`)
For CSS that must be available before the first paint, use the .inline.css file extension. This inlines the CSS content directly as a <style> tag in the HTML <head>, eliminating the network request entirely.
// inlined as <style> in HTML — zero network latency
import './critical-styles.inline.css'
// loaded via <link> tag — normal network request
import './non-critical-styles.css'When to use `.inline.css`
Use .inline.css for CSS that:
- Defines the page layout structure (prevents layout shift)
- Sets background colors or fonts visible above the fold
- Is small enough that inlining doesn't bloat the HTML (under ~10KB)
Don't use .inline.css for:
- Large CSS files (defeats the purpose — better as a cacheable
<link>) - CSS only needed after user interaction
- Component-specific CSS that isn't visible on first paint
How it works
- The
.inline.cssextension is detected during the build — Vite processes the CSS normally - The plugin tracks which CSS files should be inlined
- At HTML generation time, inline CSS is read from the build output and inlined as
<style>tags - Other CSS remains as
<link rel="stylesheet">tags
With `inlineLayoutCSS`
.inline.css works independently of the inlineLayoutCSS config option:
inlineLayoutCSS: true— inlines ALL CSS as<style>tags.inline.css— inlines only the marked files, leaves the rest as<link>tags- Both together —
inlineLayoutCSStakes precedence and inlines everything
TypeScript
The .inline.css extension works with any CSS file. Since it's a side-effect import (no default export), TypeScript handles it without additional type declarations:
import './styles.inline.css' // works out of the boxComparison
| Approach | What it does | Use case |
|---|---|---|
| Default | Layout CSS before scripts, page CSS after | Most apps |
.inline.css |
Specific files inlined as <style> |
Above-the-fold CSS |
inlineLayoutCSS: true |
All CSS inlined as <style> |
Small apps, maximum LCP |
Apps Onestack.Dev Data Docs Guides Dark Mode (`apps/onestack.dev/data/docs/guides-dark-mode.mdx`)
title: Dark Mode
Supporting SSR safe styling while also supporting Native and web requires a lot of work,
until now.
There's about one cross-platform React style library that has excellent SSR support - Tamagui 👌.
Tamagui gives you styling out of the box that works perfectly with no flickers, and even renders properly with JS turned off (give it a try on this very page).
Using our style-library-agnostic @vxrn/color-scheme library, you can also let your users choose between three options: light, dark, or system settings - also, fully SSR safe. one re-exports everything from @vxrn/color-scheme, so you can import these from either package.
Finally, there's another wrinkle in the theme-color meta tag - it also requires some fancy scripting to be SSR safe. Luckily, @vxrn/color-scheme helps again with the MetaTheme component.
Here's a complete example in just a few lines of code, all set up in your root _layout.tsx file. We're using Tamagui, but if you just want light/dark mode without SSR, you can swap out your own solution.
import type { ReactNode } from 'react'
import { MetaTheme, SchemeProvider, Slot, useUserScheme } from 'one'
import { TamaguiProvider, Theme } from 'tamagui'
import config from '../tamagui.config'
export default function Layout() {
return (
<SchemeProvider>
<MetaTheme darkColor="#050505" lightColor="#ffffff" />
<TamaguiRoot>
<Theme name="yellow">
<Slot />
</Theme>
</TamaguiRoot>
</SchemeProvider>
)
}
const TamaguiRoot = ({ children }: { children: ReactNode }) => {
const userScheme = useUserScheme()
return (
<TamaguiProvider disableInjectCSS config={config} defaultTheme={userScheme.value}>
{children}
</TamaguiProvider>
)
}SchemeProvider renders a small inline script that sets a t_dark or t_light class on <html> before the page paints, reading the stored setting or falling back to the system preference. It accepts:
defaultScheme: the scheme to use when the user has stored no preference, instead of the system preference.forceScheme: lock the page tolightordark, ignoring stored and system preferences.getClassName: change the class name for a scheme (defaults to(name) => `t_${name}`).
MetaTheme takes darkColor and lightColor. Its inline script picks one before hydration; after that, an optional color prop replaces the scheme's color while it is set. useUserScheme() returns { setting, value, set }: the stored setting (system, light, or dark), the resolved scheme (light or dark), and a setter. Outside React, use getUserScheme, setUserScheme, and onUserSchemeChange; useSystemScheme and getSystemScheme read the operating system's scheme.
Now lets make a toggle button:
import { Moon, Sun, SunMoon } from '~/components/icons'
import { useUserScheme } from 'one'
import { Button } from 'tamagui'
const schemeSettings = ['light', 'dark', 'system'] as const
export function ThemeToggleButton() {
const { setting, set } = useUserScheme()
const Icon = setting === 'system' ? SunMoon : setting === 'dark' ? Moon : Sun
const next = schemeSettings[(schemeSettings.indexOf(setting) + 1) % schemeSettings.length]
return (
<Button
circular
icon={<Icon size={20} />}
aria-label={`Color scheme: ${setting}`}
onPress={() => set(next)}
/>
)
}Each press cycles light, dark, and system. The stored setting survives reloads, and with system the page follows the operating system's preference.
Apps Onestack.Dev Data Docs Guides Deep Linking (`apps/onestack.dev/data/docs/guides-deep-linking.mdx`)
title: Deep Linking
Deep linking lets external URLs open a specific screen in your app on web, iOS, and Android. One handles deep links by stripping a configured prefix from the incoming URL and matching the remaining path against your file-system routes.
Quick Start
Configure router.linking in your Vite config and register the same scheme in your native app manifest.
import { one } from 'one/vite'
export default {
plugins: [
one({
native: {
app: {
name: 'MyApp',
scheme: 'myapp',
ios: { bundleId: 'com.example.myapp' },
android: { applicationId: 'com.example.myapp' },
},
},
router: {
linking: {
scheme: 'myapp',
prefixes: ['https://example.com/app'],
},
},
}),
],
}With this, opening myapp://settings/profile or https://example.com/app/settings/profile (after universal-link verification) routes to app/settings/profile.tsx.
How Prefix Matching Works
Each entry in prefixes is an exact URL prefix that gets stripped from incoming deep links before route matching. A scheme is shorthand: 'myapp' expands to 'myapp://' and 'myapp:///' automatically.
Prefixes match at clean boundaries — the URL must be exactly the prefix, or continue with /, ?, or #:
| URL | Prefix | Match | Stripped path |
|---|---|---|---|
myapp://app/thread/1 |
myapp://app |
yes | thread/1 |
myapp://app?ref=share |
myapp://app |
yes | ?ref=share |
myapp://app |
myapp://app |
yes | (empty) |
myapp://application/thread/1 |
myapp://app |
no | (falls back) |
When multiple prefixes match the same URL, the longest one wins.
The native app manifest registers URLs with iOS and Android. router.linking tells One how to strip those URLs before matching routes, so configure the scheme in both places.
`OneLinkingConfig` Reference
type OneLinkingConfig = {
scheme?: string | string[]
prefixes?: string[]
filter?: (url: string) => boolean
}scheme— one or more custom app schemes. Each scheme is expanded internally to${scheme}://and${scheme}:///URL prefixes. Schemes should also be registered in your native app manifest.prefixes— fully qualified URL prefixes to strip before route matching. Use this for universal links ('https://example.com/app') or host-bearing custom scheme URLs ('myapp://app').filter— optional predicate that gates which incoming URLs are handled. Functions can't be inlined into the generated entry, so this can only be set programmatically (see below).
The `getLinking` Helper
getLinking is a typed identity helper for defining a linking config in TypeScript. It's useful when you want to share the config across files or pass it programmatically.
import { getLinking } from 'one'
export const linking = getLinking({
scheme: 'myapp',
prefixes: ['https://example.com/app'],
})Programmatic Configuration (with `filter`)
To use a filter function, write your own entry that calls createApp({ linking }) directly. The Vite plugin's router.linking only accepts JSON-serializable fields.
import { createApp, getLinking } from 'one'
const linking = getLinking({
scheme: 'myapp',
prefixes: ['https://example.com/app'],
filter: (url) => !url.includes('/admin'),
})
createApp({
routes: import.meta.glob('./app/**/*'),
routerRoot: 'app',
linking,
})iOS Setup
The scheme in native.app lands in Info.plist after running one prebuild. To verify:
plutil -p ios/MyApp/Info.plist | grep -A 4 CFBundleURLTypesTest a deep link against a running simulator:
xcrun simctl openurl booted myapp://settings/profileFor universal links (https://example.com/app/...), follow Apple's associated domains setup and add the entitlement.
Android Setup
one prebuild registers the native.app scheme in AndroidManifest.xml. Add universal-link hosts to the generated native project, then test with:
adb shell am start -W -a android.intent.action.VIEW -d "myapp://settings/profile"Web Behavior
On web, deep linking is just URL routing — no prefix-stripping is needed because the browser already provides the path via window.location. The configured prefixes and scheme are still accepted for API parity but are inert on web.
Inspecting Routes with `useSitemap`
Use useSitemap to render a list of all routes — handy for debug screens or generating link previews while testing deep links.
Related
useSitemap— list every route in your app- Configuration — full Vite plugin reference
- Routing Overview — file-system routing fundamentals
Apps Onestack.Dev Data Docs Guides Deployment (`apps/onestack.dev/data/docs/guides-deployment.mdx`)
title: Deployment
One supports multiple deployment targets for web applications. Choose the target that best fits your infrastructure needs.
Configuration
Set your deployment target in vite.config.ts:
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
deploy: 'node' // 'node' (default), 'vercel', or 'cloudflare'
},
build: {
// fail the build if secrets leak into client bundles
securityScan: 'error'
}
})
]
}We recommend setting securityScan: 'error' for production deployments to prevent accidentally shipping API keys or tokens in client-facing JavaScript. See Configuration: build.securityScan for details.
Deployment Targets
Node (Default)
The default deployment target outputs a Node.js server using Hono. This is suitable for any Node.js hosting environment.
# Build for production
npx one build
# Start the production server
npx one serveThe build output is in the dist directory:
dist/client- Static assets and client-side bundlesdist/server- Server-side rendering bundlesdist/api- API route handlers
You can deploy this to any platform that supports Node.js:
- Docker: Create a Dockerfile that runs
npx one serve - Render, Fly.io: Point to your repo and set the start command to
npx one serve - AWS, GCP, Azure: Deploy as a containerized application or using their Node.js runtimes
Vercel
One supports deploying to Vercel's serverless infrastructure using the Build Output API.
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
deploy: 'vercel'
}
})
]
}Project Setup
- Add vercel.json to your project root:
{
"framework": null,
"cleanUrls": true,
"buildCommand": "npx one build",
"installCommand": "npm install"
}Setting framework: null tells Vercel to use the pre-built output from the Build Output API instead of auto-detecting a framework.
The cleanUrls: true setting is important for SSG routes - it allows URLs like /about to serve about.html without needing the .html extension. Without this, direct navigation to SSG pages will 404.
- Install the Vercel CLI (optional, for manual deploys):
npm install vercelGit Push Deploys
The easiest way to deploy is connecting your Git repository to Vercel:
- Go to vercel.com/new
- Import your Git repository
- Vercel auto-detects settings from
vercel.json - Click Deploy
Once connected, every push to your main branch triggers a production deploy. Pull requests get preview deployments automatically.
Manual CLI Deploy
For manual deploys or CI/CD pipelines:
# Build for Vercel
npx one build
# Deploy using Vercel CLI
vercel deploy --prebuiltThe --prebuilt flag tells Vercel to use the pre-generated output in .vercel/output instead of running its own build process.
For production deploys:
vercel deploy --prebuilt --prodTesting Production Build Locally
You can do a basic sanity check of your production build locally:
# Build the app
npx one build
# Serve the production build
npx one serveNote: This runs the Hono server from dist/, not the actual Vercel serverless functions. It's useful for catching build errors and checking that pages render correctly, but it's not a perfect simulation of the Vercel environment.
For true Vercel testing, use a preview deploy:
# Deploy a preview (not production)
vercel deploy --prebuiltEvery push to a non-main branch also triggers a preview deployment automatically when using Git integration. This is the most reliable way to test before promoting to production.
What Gets Built
When targeting Vercel, One generates:
.vercel/output/static- Static assets served from Vercel's Edge Network.vercel/output/functions- Serverless functions for:- SSR pages (
+ssr.tsxfiles) - API routes (
app/api/**) - Loaders (data fetching functions)
- SSR pages (
Static routes (+ssg.tsx) are pre-rendered to .vercel/output/static as HTML files.
Environment Variables
Set environment variables in your Vercel project dashboard under Settings > Environment Variables:
# Required for SSR and API routes
ONE_SERVER_URL=https://your-app.vercel.appFor local development, add to .env.local:
ONE_SERVER_URL=http://localhost:8081Vercel Project Settings
When using vercel.json, settings are auto-configured. If you need to configure manually in the Vercel dashboard:
- Framework Preset: Other
- Build Command:
npx one build - Output Directory:
.vercel/output - Install Command:
npm install(orbun install,pnpm install)
Static-Only Sites
If your site only uses SSG routes (+ssg.tsx) without SSR, loaders, or API routes, you can simplify:
one({
web: {
deploy: 'vercel',
defaultRenderMode: 'ssg',
}
})This generates only static files with no serverless functions, resulting in faster deploys and lower costs.
Troubleshooting
Build fails with "Cannot find module": Ensure all dependencies are in dependencies, not devDependencies, if they're used at runtime.
404 on dynamic routes: Check that your route files use the correct suffix (+ssr.tsx for server-rendered, +ssg.tsx with generateStaticParams for static).
Environment variables not available: Variables must be added in Vercel dashboard and redeployed. Local .env files are not uploaded.
Slow cold starts: Consider using +ssg.tsx for routes that don't need real-time data. Static routes have no cold start.
Static Export
If you're only using SSG and SPA routes without loaders or API routes, you can statically serve the dist/client directory from any static hosting:
- Netlify
- GitHub Pages
- Cloudflare Pages (static only)
- AWS S3 + CloudFront
- Any CDN or static file server
npx one build
# Upload dist/client to your static hostCloudflare Workers
One supports deploying to Cloudflare Workers with full SSR, API routes, and edge performance.
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
deploy: 'cloudflare'
}
})
]
}Build and Deploy
# Build for Cloudflare
npx one build
# Deploy using Wrangler
wrangler deploy --config dist/worker/wrangler.jsonContinuous Deployment
Wrangler is non-interactive in CI, so authenticate with an API token and
account ID instead of wrangler login. Store both as secrets in your CI
provider:
CLOUDFLARE_API_TOKEN: a token with the Edit Cloudflare Workers
permission, scoped to the account holding the workerCLOUDFLARE_ACCOUNT_ID: the account ID the worker deploys to
A GitHub Actions job then only needs to build and deploy:
- run: npx one build
- run: wrangler deploy --config dist/worker/wrangler.json
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}See Cloudflare's GitHub Actions guide for token setup details.
What Gets Built
When targeting Cloudflare, One generates:
dist/worker/index.js- The Cloudflare Worker entry pointdist/worker/wrangler.json- Wrangler configuration for the generated workerdist/client- Static assets served from Cloudflare's edgedist/server- Server-side bundles (lazy-loaded per route)dist/api- API route handlers (lazy-loaded per route)dist/middlewares- Middleware bundles used by the worker runtime
Lazy Loading
One uses a lazy loading pattern for Cloudflare Workers. Route modules are loaded on-demand when matched, not all upfront. This improves cold start times for apps with many routes.
The generated worker config lives at dist/worker/wrangler.json and is ready to deploy with Wrangler. The worker serves static assets from ../client and loads route bundles from the generated dist/server, dist/api, and dist/middlewares outputs as requests come in.
{
"main": "index.js",
"no_bundle": true,
"rules": [{ "type": "ESModule", "globs": ["assets/**/*.js", "assets/**/*.mjs"] }],
"assets": {
"directory": "../client",
"binding": "ASSETS",
"run_worker_first": true
}
}Accessing Bindings and `waitUntil`
API route handlers receive the worker runtime's env and executionCtx on their second argument as worker:
import { createAPIRoute } from 'one'
export const POST = createAPIRoute(async (request, { worker }) => {
// env holds the bindings you defined in wrangler.json (KV, D1, R2, secrets, ASSETS)
const db = worker?.env?.DB
// waitUntil keeps the worker alive for fire-and-forget work after the
// response is sent — useful for analytics, logging, cache warming, etc.
worker?.executionCtx?.waitUntil(trackAsync(request))
return Response.json({ ok: true })
})worker is undefined outside the worker runtime (dev/Node), so the optional chaining keeps the handler runtime-portable. See the API Routes doc for full details.
Requirements
- Cloudflare Workers with
nodejs_compatcompatibility flag (auto-configured) - Wrangler CLI for deployment
Wrangler Configuration
The generated worker config is ready to use. To customize it, create your own wrangler.jsonc in your project root and One will merge it into the generated dist/worker/wrangler.json.
One preserves build-required settings like main, assets.directory, assets.binding, assets.run_worker_first, the generated worker chunk rules, and it always includes nodejs_compat in compatibility_flags.
Key settings:
{
"name": "your-app-name",
"compatibility_flags": ["nodejs_compat"],
"assets": { "directory": "../client" }
}Apps Onestack.Dev Data Docs Guides Eas (`apps/onestack.dev/data/docs/guides-eas.mdx`)
title: Build and Deliver Native Apps with EAS
EAS Build and EAS Update are covered in
Coming from Expo. For One's own
over-the-air updates, see Over-the-air Updates.
Apps Onestack.Dev Data Docs Guides Headless Web (`apps/onestack.dev/data/docs/guides-headless-web.mdx`)
title: Headless Web Navigators
On web, <Stack>, <Tabs>, and <Drawer> render headless: just the matched route's element. No tab bar, no header, no wrapper elements, no styles. Native is unchanged - react-navigation still renders full chrome exactly as before.
import { Tabs } from 'one'
export default function Layout() {
return (
<Tabs>
<Tabs.Screen name="home" options={{ title: 'Home' }} />
<Tabs.Screen name="feed" options={{ title: 'Feed' }} />
</Tabs>
)
}On native this renders a bottom tab bar as usual. On web it renders only the focused tab's screen - no bar. Your site's own nav is your tab bar.
Why
One's web build doesn't ship react-native-web. Rendering full react-navigation chrome (tab bars, headers, drawers) on web meant carrying RNW just to draw them, so on web the navigators stopped rendering any of that and instead expose the underlying navigation state as data. Bring your own DOM, Tamagui, or whatever you already use to build your site's chrome.
What each navigator renders by default
- Stack: the focused screen. Screens with a
presentation(sheet,modal,formSheet, ...) render as overlays above the base screen - inline with no floating chrome by default, or with real chrome once you mount Presentations. - Tabs: the focused tab's screen, no bar.
- Drawer: the focused screen. Open state lives in navigation state - build your own sidebar with useDrawer.
- Slot: unchanged, it was always the identity.
Keeping screens mounted
Set keepMounted on a screen and the navigator's default web view keeps it alive once you navigate away, hidden via React 19.2's <Activity mode="hidden"> instead of unmounting, so scroll position and form state survive.
<Stack.Screen name="settings" options={{ keepMounted: true }} />It works the same in all three navigators. In Tabs and Drawer a screen is only kept once it has actually been focused, matching react-navigation's lazy behavior on native. Custom layouts render whatever they want, so they own this themselves - keepMounted is on every entry from useStack, useTabs, and useDrawer.
Building your own layout
Add a component as a child of the navigator. It replaces the headless default on web.
// app/(tabs)/_layout.tsx, works on both platforms
import { Link, Tabs, useTabs } from 'one'
function WebTabBar() {
const { screens, focused } = useTabs()
return (
<>
<nav>
{screens.map((tab) => (
<Link
key={tab.name}
href={tab.href}
className={tab.isFocused ? 'active' : undefined}
>
{tab.options.title}
</Link>
))}
</nav>
{focused.element}
</>
)
}
export default function Layout() {
return (
<Tabs>
<Tabs.Screen name="home" options={{ title: 'Home' }} />
<Tabs.Screen name="feed" options={{ title: 'Feed' }} />
<WebTabBar />
</Tabs>
)
}The mechanical rule: a navigator child that is not config (Screen, Protected, Stack.Header) replaces the headless default on web, and is ignored on native. The same layout file works on both platforms. Native keeps rendering its usual chrome from the Screen config, while web renders WebTabBar instead. If a custom layout ever needs a different native version too, split it into a .web.tsx file the same way you would any other platform-specific component.
Each navigator has its own hook returning its full state: useStack, useTabs, useDrawer.
Sheets and modals
Stack screens with presentation: 'sheet' or presentation: 'modal' work on web with no setup - they render inline, in place, with no floating position or backdrop (headless doesn't add styling for you).
To give them real sheet/modal chrome, mount Presentations once in your root layout:
// app/_layout.tsx
import { Presentations, Slot } from 'one'
import { Modal } from '~/interface/Modal'
import { Sheet } from '~/interface/Sheet'
export default function Layout() {
return (
<Presentations web={{ sheet: Sheet, modal: Modal }}>
<Slot />
</Presentations>
)
}Sheet and Modal are ordinary components receiving open, onOpenChange, options, screen, and children.
Any layout that declares a sheet or modal screen picks this up automatically, using the same presentation/sheetAllowedDetents vocabulary you'd write for native:
<Stack>
<Stack.Screen
name="compose"
options={{ presentation: 'sheet', sheetAllowedDetents: [0.6, 1] }}
/>
</Stack>Omit sheet or modal and that kind keeps its headless default, so you only need to write the presentations you actually use.
If your app renders react-native components on web
The react-native to react-native-web alias is still there for your own code, so import { View } from 'react-native' keeps working on web. What changed is that One's own web output no longer contains any of it, so an app that doesn't import react-native itself never loads it.
One also stopped extracting react-native-web's generated stylesheet during SSR, since that only mattered when the framework rendered react-native components for you. If your app renders them on web and you want their styles in the server HTML instead of after hydration, do the extraction in your root layout:
import { Slot, useServerHeadInsertion } from 'one'
import { AppRegistry } from 'react-native'
export default function Layout() {
useServerHeadInsertion(() => {
AppRegistry.registerComponent('styles', () => () => null)
// @ts-expect-error web-only api
return AppRegistry.getApplication('styles', {}).getStyleElement()
})
return <Slot />
}Longer term the recommendation is to drop react-native-web on web. It's in maintenance mode, and One gives you the DOM directly. Use Tamagui, React Strict DOM, or plain elements with CSS for shared primitives.
Native
Native is unaffected by any of this. <Stack>, <Tabs>, and <Drawer> still render through React Navigation with real headers, a real bottom tab bar, and a real drawer. Nothing about your Screen config, screenOptions, or Stack.Header composition changes.
Related
Apps Onestack.Dev Data Docs Guides Images (`apps/onestack.dev/data/docs/guides-images.mdx`)
title: Images
One provides a built-in way to import images with their metadata at build time using the ?imagedata suffix. This gives you automatic width, height, and blur placeholder data without any runtime cost.
Setup
Install sharp to enable image processing:
npm install sharpIf sharp is not installed, imports will still work but return 0 for dimensions and an empty blur placeholder. A console warning will be shown during build to remind you to install it.
Usage
Import any image with the ?imagedata suffix:
import heroImage from '/hero.jpg?imagedata'
// heroImage = {
// src: '/hero.jpg',
// width: 1920,
// height: 1080,
// blurDataURL: 'data:image/jpeg;base64,...'
// }The returned object can be spread directly onto any image component:
import heroImage from '/hero.jpg?imagedata'
// Native img element
<img {...heroImage} alt="Hero" />
// Or with explicit props
<img
src={heroImage.src}
width={heroImage.width}
height={heroImage.height}
style={{ backgroundImage: `url(${heroImage.blurDataURL})` }}
alt="Hero"
/>Import paths
The plugin supports both public directory and relative imports:
// From public directory (starts with /)
import hero from '/images/hero.jpg?imagedata'
// Relative to current file
import avatar from './avatar.png?imagedata'
import logo from '../assets/logo.svg?imagedata'TypeScript
Types are included automatically. The import returns an ImageData object:
import type { ImageData } from 'one'
// ImageData = {
// src: string
// width: number
// height: number
// blurDataURL: string
// }Blur placeholders
The blur placeholder is a tiny (10px wide) base64-encoded JPEG that can be used as a low-quality image placeholder (LQIP) while the full image loads:
import hero from '/hero.jpg?imagedata'
function HeroImage() {
const [loaded, setLoaded] = useState(false)
return (
<div style={{ position: 'relative' }}>
{/* Blur placeholder */}
<img
src={hero.blurDataURL}
width={hero.width}
height={hero.height}
style={{
position: 'absolute',
filter: 'blur(20px)',
transform: 'scale(1.1)',
opacity: loaded ? 0 : 1,
transition: 'opacity 0.3s',
}}
alt=""
/>
{/* Full image */}
<img
{...hero}
onLoad={() => setLoaded(true)}
alt="Hero"
/>
</div>
)
}Programmatic usage
For one-off usage or build scripts, use the getImageData helper:
import { getImageData } from 'one/image'
// Paths starting with / resolve from ./public
const heroData = await getImageData('/images/hero.jpg')
// { src: '/images/hero.jpg', width: 1920, height: 1080, blurDataURL: '...' }
// Or use relative paths from your current file
const logoData = await getImageData('../public/images/logo.png')MDX frontmatter integration
When using @vxrn/mdx, images in frontmatter are automatically processed with getMDX. If your MDX file has:
---
title: My Post
image: /images/post-hero.jpg
---The frontmatter will include imageMeta with dimensions and blur placeholder:
import { getMDX } from '@vxrn/mdx'
const { frontmatter } = await getMDX(source, { publicDir: './public' })
// frontmatter.image = '/images/post-hero.jpg'
// frontmatter.imageMeta = { width: 1200, height: 630, blurDataURL: '...' }Supported formats
The plugin uses sharp for image processing, which supports:
- JPEG / JPG
- PNG
- WebP
- AVIF
- GIF
- TIFF
- SVG (dimensions only, no blur placeholder)
Benefits
- Build-time processing - No runtime cost, dimensions are computed during build
- Prevents layout shift - Width and height allow browsers to reserve space before images load
- Blur placeholders - Improve perceived performance with LQIP technique
- Type-safe - Full TypeScript support with autocomplete
- Works everywhere - Compatible with any image component (native
, React Native, Tamagui, etc.) - MDX integration - Frontmatter images are automatically processed
Apps Onestack.Dev Data Docs Guides Ios Apis (`apps/onestack.dev/data/docs/guides-ios-apis.mdx`)
title: Calling iOS APIs
The iOS API reference lists the frameworks, types and members included in One's generated SDK bindings. It lives separately from these guides because it is regenerated from the same SDK symbol graphs and binding entries as the native code.
Start with a framework such as CoreBluetooth, then choose a type such as CBCentralManager. Each type page shows its Swift declarations, available TypeScript call shapes, iOS availability and any SDK documentation comments. Search the reference sidebar to find a type across frameworks.
Coverage describes the generated snapshot. A framework page also lists skipped declarations and explains why each is unavailable. Check the declaration's iOS version before calling it. Your app still needs Apple's required permissions, usage descriptions and entitlements.
For APIs with output parameters, leave those parameters out of the call. The returned object contains $ret for the return value and $out for the named output values.
Background method calls
Use callSDKMethodAsync from one/ios-runtime to await an instance method that does blocking native work. It keeps the generated method's argument and return types:
import type { CNContactStore, CNSaveRequest } from 'one/ios/Contacts'
import { callSDKMethodAsync } from 'one/ios-runtime'
async function saveContacts(store: CNContactStore, request: CNSaveRequest) {
await callSDKMethodAsync(store, 'execute', request)
}A synchronous method runs on a background queue when it has no main actor requirement. Main actor methods and native async methods retain their declared execution rules. Choose a background call only for APIs that support background access. Methods requiring synchronous JavaScript callbacks are rejected before native execution.
Native failures throw an Error with code: 'E_SDK', the original NSError domain, and its numeric nativeCode. Use these fields to classify a failure independently of its localized message. Calls, returned functions, and subscription errors preserve the same metadata.
React state from native objects
Two hooks from one/ios-runtime turn changing native values into React state. Each returns { status, value, error }, where status is 'pending', 'value' or 'error'. Subscriptions start when the component mounts and stop when it unmounts or its arguments change.
useSDKProperty(object, property) rerenders when a native property changes:
import { Text } from 'react-native'
import { Progress } from 'one/ios/Foundation'
import { useSDKProperty } from 'one/ios-runtime'
function DownloadProgress({ progress }: { progress: Progress }) {
const state = useSDKProperty(progress, 'fractionCompleted')
return <Text>{state.value ?? 0}</Text>
}The generator chooses how to watch each property. Objective-C properties use key-value observing, and properties of Swift classes that adopt Observable use Observation tracking. TypeScript only accepts properties that can be watched one of these ways. Key-value observing reports a change only when the framework sends one, so check Apple's documentation to see whether a property supports it.
useSDKSequence(handle) follows an AsyncSequence or a Combine publisher and holds its latest element:
import { Text } from 'react-native'
import { NotificationCenter, NSNotification_Name } from 'one/ios/Foundation'
import { useSDKSequence } from 'one/ios-runtime'
const name = NSNotification_Name.init('app.refresh')
const notifications = NotificationCenter.default.notifications(name)
function LastRefresh() {
const state = useSDKSequence(notifications)
return <Text>{state.status === 'value' ? String(state.value.object) : 'waiting'}</Text>
}The element type comes from the declaration, so state.value above is a Notification. An async sequence keeps iterating while no component listens, and a remounted component starts from its latest element. A publisher subscription starts fresh on each mount. Keep a handle you create in useMemo or module scope; a new handle on every render starts a new subscription. Pass null or undefined to leave the hook pending.
For app setup and custom native modules, see One Native setup and Swift and Kotlin.
Apps Onestack.Dev Data Docs Guides Ios Native (`apps/onestack.dev/data/docs/guides-ios-native.mdx`)
title: Build or Run the Native iOS App
This guide explains how to build and upload your iOS app to the App Store or TestFlight, or run it locally during development with custom native dependencies.
You'll use the prebuild command to generate the native code and Xcode project for iOS, open it with Xcode, and either run the app or archive it for distribution.
one prebuild generates the Xcode project from native.app invite.config.ts, and one run:ios launches it on a simulator. Apps that keep
Expo prebuild follow Coming from Expo.
Prebuild
Before you can run or build your iOS app, you'll need to generate the native code and Xcode project for iOS.
Ensure the prebuild:native script is defined in your package.json:
{
"scripts": {
"prebuild:native": "one prebuild"
}
}Then run:
yarn prebuild:nativeThis command may prompt you with some questions and could take a few minutes to install CocoaPods dependencies on its first run.
Once finished, your iOS project will be available in the ./ios directory and ready for use.
Running the App in Development
Using `one run:ios`
The simplest way to run your app after prebuild is using the CLI:
one run:iosThis will build and launch the app on your default iOS simulator. For Android, use one run:android.
Using Xcode
Alternatively, you can open the .xcworkspace file in the ./ios directory. The filename will vary based on your app's name, but you can generally open it by running this command:
open ios/*.xcworkspaceOnce Xcode opens, ➊ choose a simulator or physical device, and ➋ click the run button to build and launch the app.
Building for Production
Open the .xcworkspace file from the ios directory. You can do this by running the following command:
open ios/*.xcworkspaceOnce Xcode opens, follow these steps:
- ➊ Click your app project in the Project Navigator.
- ➋ Under “TARGETS,” select your app.
- ➌ Go to the “Signing & Capabilities” tab.
- ➍ Ensure “Automatically manage signing” is checked.
- ➎ Select a development team.
Once code signing is set up, click "Product" → "Archive" on the menu bar to start the Archive process.
After the archive process completes, Xcode will automatically open the Organizer window with your build. From there, click "Distribute App" to upload the app to the App Store or TestFlight.
Project Setup
React Native
You should add a react-native.config.cjs file in your project root to configure React Native to use Vite as the JS bundler during the native build process:
module.exports = require('one/react-native-config')This routes native build bundling through One and links One's native SwiftUI,
Compose, effects, and safe area implementation through community autolinking.
One Prebuild
Configure the native application in vite.config.ts, then generate the React Native community projects with one prebuild:
one({
native: {
app: {
name: 'MyApp',
icon: {
source: './public/app-icon.png',
backgroundColor: '#000000',
},
splash: {
source: './public/splash.png',
backgroundColor: '#000000',
width: 200,
},
ios: {
bundleId: 'com.example.myapp',
usesNonExemptEncryption: false,
fileSharing: true,
},
android: { applicationId: 'com.example.myapp' },
imagePicker: {
camera: 'Take profile photos.',
},
},
},
})One reads this manifest directly. It generates the iOS and Android app icons
and launch screens from the shared sources. imagePicker.camera is the iOS camera usage description; prebuild
writes it to NSCameraUsageDescription and declares the Android camera permission,
both required for One.ImagePicker.launchCamera. The icon must be a square image at least 1024 pixels wide. Splash
artwork is centered at width points on iOS and density-independent pixels on
Android. One removes a solid outer border matching backgroundColor before sizing
the artwork, then generates density-specific Android assets inside a 288 dp square. The launch screen
stays up until React Native draws its first content, so it never gives way to an
empty root.
ios.fileSharing sets UIFileSharingEnabled plusLSSupportsOpeningDocumentsInPlace in the generated Info.plist. The user sees
the app's Documents as a folder in the Files app and in document pickers under
On My iPhone, and files the app saves there can be opened in place.
App appearance, links and store settings
The rest of native.app covers appearance, links, and store settings:
orientation:portrait,landscapeordefault(all four). Unset keeps a
portrait-only phone.userInterfaceStyle:lightordarklocks the appearance;automatic,
like unset, follows the system.splash.resizeMode:coverfills the iOS launch screen with untrimmed
artwork;contain(the default) centers it atwidth.splash.dark:backgroundColor, and optionallysourceandbackgroundImage, for a launch in dark appearance.splash.backgroundImage: a full-bleed image, such as a gradient, under the
artwork on the launch screen: iOS aspect-fills it, Android stretches it to
fill (a gradient renders the same either way). Android's system splash
itself shows only the color and icon.- The launch screen stays up until the first screen draws. To reveal the app
later (after a cache loads), callOne.LaunchScreen.preventAutoHide()while
modules evaluate andOne.LaunchScreen.hide({ fade: true })when ready. notifications.apsEnvironment:productionfor a build signed for
TestFlight or the App Store; push usesdevelopmentotherwise.ios.accentColor:{ light, dark }hex colors for the app's tint (tab
selection, text cursor, system controls), read in JS asPlatformColor('AccentColor').fonts: project-relative.ttfor.otffiles bundled into the app. iOS
lists them inUIAppFonts; Android loads them fromassets/fonts. Use the
font's own family name in styles.android.adaptiveIcon:foreground, thenbackground(an image) orbackgroundColor, and an optionalmonochromelayer for Android 13 themed
icons. The foreground is a 108 dp square with the artwork inside the central
66 dp.ios.associatedDomains(such asapplinks:example.com) andandroid.appLinks({ host, pathPrefix }, verified withautoVerify) route
https links into the app.ios.usesAppleSignInadds the Sign in with Apple entitlement.android.permissionsadds manifest permissions (a bare name meansandroid.permission.<name>);android.blockedPermissionsremoves ones a
library merges in.android.targetSdkandandroid.compileSdkset the Gradle SDK levels.ios.googleServicesFileandandroid.googleServicesFilebundle a FirebaseGoogleService-Info.plistandgoogle-services.json; Android also gets thegoogle-servicesGradle plugin. For Google Sign-In, add the plist'sREVERSED_CLIENT_IDtoscheme.android.minifyruns R8 on release builds,android.shrinkResources(which
needsminify) drops unused resources, andandroid.proguardRulesappends
keep rules toproguard-rules.pro.ios.infoPlistandios.entitlementsadd keysnative.appdoes not model,
such asNSUserTrackingUsageDescription. A key the template or another field
already writes fails prebuild; set it through that field.
iOS widgets and Live Activities
Set ios.widgets to have one prebuild --platform ios generate a WidgetKit extension,
App Group entitlements for both targets, and a small SwiftUI widget and Live Activity.
The app writes a typed payload to the App Group. The extension reads that payload
without running the app's JavaScript.
ios: {
bundleId: 'com.example.myapp',
deploymentTarget: '17.0',
widgets: {
appGroup: 'group.com.example.myapp',
kind: 'MyAppStatus',
displayName: 'My status',
description: 'Shows the latest status.',
},
}import { One } from 'one'
await One.Widgets.write({ title: 'Order', value: '2 of 3', subtitle: 'On the way' })
const id = await One.LiveActivities.start('Order', { status: 'Preparing', value: '1 of 3' })
await One.LiveActivities.update(id, { status: 'On the way', value: '2 of 3' })
const token = await One.LiveActivities.pushToken(id)
const unsubscribe = One.LiveActivities.onPushToken(({ id, token }) => {
// send the ActivityKit token to your server
})
await One.LiveActivities.end(id)
unsubscribe()Pass true as the third argument to start to request an ActivityKit push token.
Set ios.widgets.pushNotifications: true before prebuild to add the APNs
entitlement; the signing profile must also enable Push Notifications.
If notifications.push is already enabled, One includes that entitlement in
the same app entitlement file as the widget's App Group.pushToken returns the current token or null while iOS has not issued one;onPushToken reports later token changes. These APIs are separate from notification
permissions and device tokens. App Groups and Live Activities need the corresponding
capabilities in the app's signing profile on a physical device.
One also accepts JSX layouts through One.iOS.WidgetUI. The app serializes the
layout to the same typed App Group contract or ActivityKit state. The generated
SwiftUI extension renders it without a JavaScript runtime or an extra package.
import { One } from 'one'
const W = One.iOS.WidgetUI
function OrderStatus({ progress, step }: { progress: string; step: number }) {
return (
<W.VStack style={{ padding: 12, spacing: 8 }}>
<W.HStack>
<W.Image systemName="shippingbox.fill" style={{ color: '#1685B1' }} />
<W.Text style={{ fontSize: 18, fontWeight: 'bold' }}>Order</W.Text>
</W.HStack>
<W.Text>{progress}</W.Text>
<W.Progress value={step} total={3} style={{ color: '#1685B1' }} />
</W.VStack>
)
}
await One.Widgets.writeView(<OrderStatus progress="2 of 3" step={2} />)
const id = await One.LiveActivities.startView('Order', {
lockScreen: <OrderStatus progress="1 of 3" step={1} />,
compactLeading: <W.Text>Order</W.Text>,
compactTrailing: <W.Text>1/3</W.Text>,
expandedBottom: <OrderStatus progress="1 of 3" step={1} />,
})
await One.LiveActivities.updateView(id, {
lockScreen: <OrderStatus progress="2 of 3" step={2} />,
compactLeading: <W.Text>Order</W.Text>,
compactTrailing: <W.Text>2/3</W.Text>,
expandedBottom: <OrderStatus progress="2 of 3" step={2} />,
})
await One.LiveActivities.end(id)The activity view also accepts expandedLeading, expandedTrailing, andexpandedBottom for the expanded Dynamic Island. The compact slots andminimal cover its other presentations.
WidgetUI supports Text, Image (SF Symbols), VStack, HStack,ZStack, Spacer, Divider, Progress, Gauge, Circle, Rectangle,RoundedRectangle, and Link. Styles include color, background color, font size,
weight and design, padding, corner radius, spacing, size, opacity, line limit,
and alignment. Colors use six-digit hex values. JSX layouts can contain pure
function components; compute hook state in the app and pass it as props. Thewrite and writeView methods update the same widget. Android widgets require
a separate Glance target, storage contract, and lifecycle; this setting
generates only iOS targets.
iOS Project Setup
In the "Bundle React Native code and images" phase, make the following change in the shell script:
# ⬇️ Add this line directly above the line that calls react-native-xcode.sh
export CLI_PATH="$("$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/cli.js'")"
`"$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'"`Troubleshooting
`ios/*.xcworkspace` isn't there
Try cd ios && pod install && cd ...
Error: `node: No such file or directory` during Xcode build
This often comes with Node found at: /private/var/folders/.../T/xfs-.../node and then /private/var/folders/.../T/xfs-.../node: No such file or directory.
Try to delete ios/.xcode.env.local.
Error: `No Metro config found`
Make sure there's a
react-native.config.cjsin your project which contains something like this:jsmodule.exports = require('one/react-native-config')Confirm
native.app.ios.bundleIdis set invite.config.ts.Delete the generated
iosdirectory and runone prebuildagain.
Apps Onestack.Dev Data Docs Guides Isr (`apps/onestack.dev/data/docs/guides-isr.mdx`)
title: Incremental Static Regeneration (ISR)
Incremental Static Regeneration (ISR) lets you serve SSR pages with static-like performance by caching them at the CDN edge. Pages are generated on the first request, cached, then served instantly to subsequent visitors while being regenerated in the background.
How ISR Works
ISR uses HTTP cache headers to tell CDNs when to cache and when to revalidate:
- First visitor requests a page → SSR renders it → CDN caches the result
- Subsequent visitors get the cached version instantly
- After the cache expires, the next visitor still gets the cached version while the CDN fetches a fresh one in the background
- Once regenerated, the new version replaces the cached one
No one ever waits for rendering - the CDN always serves something immediately.
Setting Up ISR
Use setResponseHeaders in your SSR loader to set cache headers:
import { setResponseHeaders, useLoader } from 'one'
export async function loader({ params }) {
// Set ISR cache headers
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
})
const post = await fetchPost(params.slug)
return { post }
}
export default function BlogPost() {
const { post } = useLoader(loader)
return <article>{post.content}</article>
}Cache Header Patterns
Basic ISR (1 hour cache, 1 day stale)
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')public- Allow CDN cachings-maxage=3600- CDN caches for 1 hour (3600 seconds)stale-while-revalidate=86400- Serve stale content for up to 1 day while revalidating
Aggressive caching (1 day cache)
For content that rarely changes:
headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=604800')Short cache (5 minutes)
For frequently updated content:
headers.set('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=3600')No caching (dynamic)
For user-specific or real-time content:
headers.set('Cache-Control', 'private, no-store')Conditional Caching
Cache public pages, skip for authenticated users:
export async function loader({ request }) {
const cookies = request?.headers.get('Cookie') || ''
const isAuthenticated = cookies.includes('session=')
if (isAuthenticated) {
// Don't cache user-specific content
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'private, no-store')
})
return { user: await getUser(cookies) }
}
// Cache the public version
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
})
return { user: null }
}API Routes
For API routes, set headers directly on the Response:
export async function GET(request: Request) {
const posts = await fetchPosts()
return new Response(JSON.stringify(posts), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=3600',
},
})
}CDN Compatibility
ISR requires a CDN that supports stale-while-revalidate:
| Platform | Support |
|---|---|
| Vercel | Full support |
| AWS CloudFront | Full support |
| Fastly | Full support |
| Cloudflare | No stale-while-revalidate support |
For Cloudflare, use s-maxage only - pages will be fully regenerated after cache expires rather than served stale during revalidation.
When to Use ISR vs SSG vs SSR
| Strategy | Best For | Trade-offs |
|---|---|---|
SSG (+ssg.tsx) |
Content known at build time | No runtime cost, but requires rebuild for updates |
ISR (+ssr.tsx + cache headers) |
Content that changes occasionally | Fast after first request, fresh within cache window |
SSR (+ssr.tsx, no cache) |
User-specific or real-time content | Always fresh, but slower |
Use ISR when:
- Content updates periodically but doesn't need instant updates
- You have many pages that can't all be pre-built (e.g., large blog, product catalog)
- You want SSR flexibility with near-static performance
Use SSG when:
- Content is known at build time
- Updates can wait for a rebuild
- You want zero runtime cost
Use SSR (no cache) when:
- Content is user-specific
- Data must be real-time
- Content varies per request (A/B tests, geolocation)
Example: Blog with ISR
import { setResponseHeaders, useLoader } from 'one'
import { getPost } from '../lib/posts'
export async function loader({ params }) {
const post = await getPost(params.slug)
if (!post) {
throw new Response('Not Found', { status: 404 })
}
// Cache for 1 hour, serve stale for up to 1 week
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=604800')
})
return { post }
}
export default function BlogPost() {
const { post } = useLoader(loader)
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div>{post.content}</div>
</article>
)
}First visitor to /blog/my-post triggers SSR. The CDN caches the result. For the next hour, all visitors get the cached version instantly. After an hour, the next visitor still gets the cached version while the CDN regenerates in the background.
Apps Onestack.Dev Data Docs Guides Loader Redirects (`apps/onestack.dev/data/docs/guides-loader-redirects.mdx`)
title: Loader Redirects
Loader redirects let you protect routes server-side on both web and native. When a loader calls redirect(), unauthorized users are redirected before any sensitive data or page content reaches the client — for direct page loads, client-side <Link> navigation, and React Native navigation.
Basic Usage
Use the redirect() helper in a loader to guard a route. Use +ssr so the loader runs on every request:
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.
How It Works
Direct Page Load
When a user navigates directly to a protected URL (typing it in, refreshing, or following an external link), the loader runs on the server. If it calls redirect(), the server returns a standard HTTP 302 response and the browser redirects to the target page. The protected page never renders.
Client-Side Navigation
When a user clicks a <Link> to a protected route, the behavior is more nuanced. 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 raw 302, try to parse the HTML login page as JavaScript, and fail.
One handles this differently:
- The server detects the redirect response from the loader
- Instead of returning a raw HTTP 302, it transforms the response into a JS module containing only redirect metadata — something like
{ __oneRedirect: "/login", __oneRedirectStatus: 302 } - The client receives this module and detects the redirect signal before navigating or rendering anything
- The client navigates to the redirect target using a
REPLACEaction
The protected page never appears. No loader data, no component JavaScript output, no flash of unauthorized content. The server only sends back the redirect path.
React Native
On native, loaders are fetched from the dev/production server and evaluated on the device. The same redirect mechanism applies:
- The native app fetches the loader JavaScript from the server
- The server runs the loader, detects the redirect, and transforms it into a module with redirect metadata
- The native client detects the
__oneRedirectsignal after evaluating the loader - The router performs a
replacenavigation to the redirect target
The component never renders with protected data. This works for both <Link> navigation and programmatic navigation via router.push() / router.navigate().
Protecting Multiple Routes
Use a shared auth helper to keep loaders clean:
import { redirect } from 'one'
import type { LoaderProps } from 'one'
export async function requireAuth(request: LoaderProps['request']) {
const session = await getSession(request)
if (!session) {
throw redirect('/login')
}
return session
}import { useLoader } from 'one'
import { requireAuth } from '~/features/auth/server'
export async function loader({ request }) {
const session = await requireAuth(request)
return { user: session.user }
}
export default function Dashboard() {
const { user } = useLoader(loader)
return <Text>Welcome, {user.name}</Text>
}import { useLoader } from 'one'
import { requireAuth } from '~/features/auth/server'
export async function loader({ request }) {
const session = await requireAuth(request)
const prefs = await getUserPreferences(session.userId)
return { prefs }
}
export default function Settings() {
const { prefs } = useLoader(loader)
return <PreferencesForm defaults={prefs} />
}Redirect Status Codes
The redirect() helper accepts an optional status code:
import { redirect } from 'one'
// 302 (default) - temporary redirect
throw redirect('/login')
// 301 - permanent redirect
throw redirect('/new-path', 301)
// 307 - temporary redirect, preserves HTTP method
throw redirect('/login', 307)For auth guards, the default 302 is usually what you want. Use 307 if you need to preserve the original HTTP method (e.g., a POST request should remain a POST after redirect).
SSR vs SSG Routes
Loader redirects work best with +ssr routes because the loader runs on every request with access to the request object (cookies, headers, etc.):
| Route Type | Loader Runs | Auth Check |
|---|---|---|
+ssr |
Every request | Checks auth per-request via cookies/headers |
+ssg |
Once at build time | No per-request auth — only checks build-time state |
+spa |
Every request | Same as SSR for loader execution |
For protected routes, always use +ssr so auth is checked on every navigation.
When an SSG route redirects at build time, One generates a static redirect file. If a runtime server is present in production, it re-evaluates the loader per-request — so authenticated users see the real page while unauthenticated users get redirected.
What Gets Protected
Loader redirects protect data. When a redirect fires:
- The loader data is never sent to the client
- The page component never renders on the server for that request
- The client receives only the redirect target path
Component JavaScript is always downloadable (this is true in every framework). Loaders protect the sensitive data your components would display — user profiles, account details, admin data, etc.
Loader Redirects vs Other Approaches
| Approach | Best For | Platform |
|---|---|---|
| Loader redirects | SSR data protection, server-side auth | Web + Native |
| `` | Hiding routes from navigation entirely | Web + Native |
<Redirect /> |
Client-side redirect in component render | Web + Native |
| Middleware | URL rewrites, API auth, request-level checks | Web (server) |
You can combine these approaches. For example, use loader redirects to protect data on SSR routes, and `` to hide those routes from the tab bar on native.
Related
- redirect - API reference for the redirect helper
- Loaders - Full loader documentation
- Authentication - Complete authentication patterns
- Middlewares - Server-side request interception
- Protected - Declarative route protection component
Apps Onestack.Dev Data Docs Guides Mdx (`apps/onestack.dev/data/docs/guides-mdx.mdx`)
title: MDX Guide
In building out this beautiful website, and the equally beautiful tamagui.dev, one of the trickier
parts was getting MDX to work well.
Since many blogs, apps, and documentation sites want to display Markdown content, we figured a guide showing how
we did it will be useful.
The @vxrn/mdx package bundles together Rehype plugins alongside mdx-bundler to provide a batteries-included MDX experience with:
- Syntax highlighting via refractor
- GitHub Flavored Markdown support
- Smart typography via remark-smartypants (with code blocks properly preserved)
- Auto-linked headings with slugified IDs
- Image metadata extraction including blur placeholders
- Reading time calculation
- Frontmatter with heading extraction for table of contents
Setting up your data dir
The first step is to create a directory for your mdx files to live:
<RouteTree
routes={[
{ name: 'data', children: [
{ name: 'hello-world.mdx', description: 'Your MDX content goes here' },
] },
]}
/>
Throw some stuff in that file:
---
title: MDX Guide
description: Setting up MDX for web
---
In building out this beautiful website, and the equally beautiful [tamagui.dev](https://tamagui.dev), the surprising
most difficult part was getting MDX to work well...Setting up `@vxrn/mdx`
Next, we add our mdx dependencies:
yarn add @vxrn/mdx mdx-bundlerWe set up our MDX components (using Tamagui in this example):
import { Text } from "react-native";
export const components = {
h1: ({ children }) => (
<Text style={{ fontSize: 24, fontWeight: "bold", marginBottom: 10 }}>
{children}
</Text>
),
h2: ({ children }) => (
<Text style={{ fontSize: 20, fontWeight: "bold", marginBottom: 8 }}>
{children}
</Text>
),
h3: ({ children }) => (
<Text style={{ fontSize: 18, fontWeight: "bold", marginBottom: 6 }}>
{children}
</Text>
),
p: ({ children }) => <Text style={{ marginBottom: 10 }}>{children}</Text>,
};And then all we need is a new route:
import { getMDXComponent } from "mdx-bundler/client";
import { useMemo } from "react";
import { useLoader } from "one";
import { Text, View } from "react-native";
import { components } from "~/features/MDXComponents";
export async function generateStaticParams() {
const { getAllFrontmatter } = await import("@vxrn/mdx");
const frontmatters = getAllFrontmatter("data");
const paths = frontmatters.map(({ slug }) => ({
slug: slug.replace(/.*docs\//, ""),
}));
return paths;
}
export async function loader({ params }) {
const { getMDXBySlug } = await import("@vxrn/mdx");
const { frontmatter, code } = await getMDXBySlug("data", params.slug);
return {
frontmatter,
code,
};
}
export function DocsPage() {
const { code, frontmatter } = useLoader(loader);
const Component = useMemo(() => getMDXComponent(code), [code]);
return (
<View style={{ padding: 20 }}>
<Text style={{ fontSize: 48, fontWeight: "bold", marginBottom: 10 }}>
{frontmatter.title}
</Text>
<Component components={components} />
</View>
);
}Note that we await import the @vxrn/mdx library - this is because One by default builds out your server
and API routes to CommonJS, but some of the rehype/remak dependencies are ESM. Long story short - this makes it work.
Configuring Vite for MDX Support
To ensure that MDX works correctly with your One project, you'll need to configure Vite. Update your vite.config.ts file with the following content:
import { defineConfig } from 'vite'
import { one } from 'one/vite'
export default defineConfig({
ssr: {
noExternal: true,
external: ['@vxrn/mdx'],
},
plugins: [
one({
web: {
defaultRenderMode: 'ssg',
},
}),
],
})This configuration does a few important things:
- It sets
noExternal: truein the SSR options, which tells Vite to bundle all dependencies for server-side rendering. - It explicitly marks
@vxrn/mdxas an external dependency, which is necessary because it contains ESM modules that shouldn't be bundled.
That should do it. We say should, because there's a lot going on here, and many sub-deps inside @vxrn/mdx that need to work together. Let us know if this works for you!
Apps Onestack.Dev Data Docs Guides Migrating Create React App Cra To Vite With One (`apps/onestack.dev/data/docs/guides-migrating-create-react-app-cra-to-vite-with-one.mdx`)
title: Migrate from Create React App (CRA) to Vite with One
Migrating from Create React App (CRA) to One is easy. In this guide we'll use the default CRA output and show how you can move over to One.
One is a React framework for web, iOS, and Android, built on the power of Vite - it's actually just a single Vite plugin. One saves you a lot of complexity when moving from CRA, it offers faster build times, built-in TypeScript support, SPA or server rendering, file system routes, and seamless cross-platform development capabilities.
Let's walk through the migration process step by step.
Step 1: Add One to Your Project
First, let's add One and its dependencies to your project:
yarn add oneStep 2: Update Your Project Structure
One uses a file-based routing system, which means we need to adjust our project structure slightly. Here's what we'll do:
<RouteTree
routes={[
{ name: 'package.json' },
{ name: 'vite.config.ts', add: true, highlight: true, description: 'Add a vite.config.ts file to configure our build system.'},
{ name: 'public', children: [
{ name: 'favicon.ico' },
{ name: 'index.html', delete: true, description: 'Remove index.html as One will handle app rendering.' },
] },
{ name: 'app', add: true, description: 'Add an app directory for our file system routes.', children: [
{ name: 'index.tsx', add: true, highlight: true, description: 'Move and rename App.js to index.tsx.' },
] },
{ name: 'src', children: [
{ name: 'App.css' },
{ name: 'App.js', delete: true, description: 'Move App.js content to app/index.tsx.' },
{ name: 'App.test.js' },
{ name: 'index.css' },
{ name: 'index.js', delete: true, description:'Remove index.js. One's file system router replaces this.' },
{ name: 'reportWebVitals.js', delete: true, description: 'Remove reportWebVitals.js.' },
{ name: 'setupTests.js', delete: true, description: 'Remove setupTests.js. We'll use Vitest for testing.' }
] }
]}
/>
Step 3: Configure Vite
Create a new vite.config.ts file in your project root with the following content:
import type { UserConfig } from 'vite'
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
defaultRenderMode: 'spa'
}
}),
],
} satisfies UserConfigThis minimal configuration sets up One with Vite, enabling all the goodness that comes with it. Note that we're setting defaultRenderMode to 'spa' (Single Page App) for compatibility with Create React App. This ensures that your app behaves similarly to how it did with CRA, rendering entirely on the client side.
The 'spa' mode is the simplest strategy, where your app is not rendered on the server at all. Instead, only the JavaScript to render your page is served to the user's browser. This avoids some of the complexity associated with server-side rendering, making it a good starting point for migrating from CRA.
If you want to explore other rendering modes later, such as Static Site Generation (SSG) or Server-Side Rendering (SSR), you can change this setting. Each mode has its own trade-offs in terms of performance, SEO, and complexity. For more information on the available render modes and their implications, refer to the One configuration documentation.
Step 4: Update package.json Scripts
Let's update our package.json scripts to use One's commands:
{
"scripts": {
"start": "one dev",
"build": "one build",
"test": "vitest run"
}
}These new scripts replace the CRA equivalents, giving you access to One's optimized development and build processes.
Step 5: Update Your Main Component
Now, let's update our main component. We'll move src/App.js to app/index.tsx and make a few adjustments:
import '~/src/App.css'
import logo from '~/src/logo.svg'
export default function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>app/index.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
)
}Notice how we've updated the import paths using the ~ alias. One supports tsconfig by default, and will also add a tsconfig.json file if you don't have one already, with the ~ path alias configured. Of course, you can change this to suit your needs.
Step 6: Remove Unnecessary Files
You can now safely remove the following files:
public/index.htmlsrc/index.jssrc/reportWebVitals.jssrc/setupTests.js
One handles app rendering and provides its own optimized setup for these functionalities.
Step 7: Run Your Migrated App
You're all set! Now you can start your newly migrated One app:
yarn startVisit http://localhost:8081 to see your app in action. Use the --port flag if you need to change the port.
Next Steps
Congratulations! You've successfully migrated from Create React App to One. Here are some next steps to consider:
Explore One's features: Check out the One documentation to learn about its powerful features like SSR, file-based routing, and more.
Optimize for production: One provides excellent production optimizations out of the box. Run
yarn buildto create an optimized production build.Add Tamagui: For a powerful, fully type-safe UI kit that works seamlessly with One, consider adding Tamagui. It provides SSR-safe styling, animations, and themes.
Implement dark mode: One makes it easy to add SSR-safe dark mode. Check out our Dark Mode guide for a quick setup.
Apps Onestack.Dev Data Docs Guides Open Graph (`apps/onestack.dev/data/docs/guides-open-graph.mdx`)
title: Dynamic OpenGraph Images
OpenGraph images are the preview images that appear when you share a link on social media. One supports dynamic OG image generation using API routes and the @vercel/og library.
Setup
Install the @vercel/og package:
npm install @vercel/ogBasic API Route
Create an API route that generates images dynamically:
import { ImageResponse } from '@vercel/og'
export const GET = async (request: Request) => {
const url = new URL(request.url)
const title = url.searchParams.get('title') || 'One Framework'
return new ImageResponse(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#000',
color: '#fff',
fontSize: 60,
fontWeight: 700,
}}
>
{title}
</div>,
{
width: 1200,
height: 630,
}
)
}This creates an endpoint at /api/og that generates PNG images on the fly.
Using in Pages
Reference the dynamic image in your page's meta tags:
import { Head, useParams } from 'one'
export default function BlogPost() {
const { slug } = useParams()
const title = 'My Blog Post'
const ogUrl = `/api/og?title=${encodeURIComponent(title)}`
return (
<>
<Head>
<meta property="og:image" content={ogUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogUrl} />
</Head>
{/* page content */}
</>
)
}Custom Fonts
Load custom fonts for better typography:
import { ImageResponse } from '@vercel/og'
import { readFile } from 'fs/promises'
import { join } from 'path'
// Load font once at module level
const interBold = readFile(join(process.cwd(), 'public/fonts/Inter-Bold.ttf'))
export const GET = async (request: Request) => {
const url = new URL(request.url)
const title = url.searchParams.get('title') || 'One Framework'
return new ImageResponse(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#000',
color: '#fff',
fontFamily: 'Inter',
fontSize: 60,
}}
>
{title}
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: await interBold,
weight: 700,
},
],
}
)
}With Background Images
Include images in your OG graphics:
import { ImageResponse } from '@vercel/og'
import { readFile } from 'fs/promises'
import { join } from 'path'
export const GET = async (request: Request) => {
const url = new URL(request.url)
const title = url.searchParams.get('title') || 'One Framework'
// Read local image as base64
const bgImage = await readFile(join(process.cwd(), 'public/og-background.png'))
const bgBase64 = `data:image/png;base64,${bgImage.toString('base64')}`
return new ImageResponse(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundImage: `url(${bgBase64})`,
backgroundSize: 'cover',
}}
>
<div
style={{
fontSize: 60,
fontWeight: 700,
color: '#fff',
textShadow: '0 2px 10px rgba(0,0,0,0.5)',
}}
>
{title}
</div>
</div>,
{ width: 1200, height: 630 }
)
}Helper Function
Create a utility to generate OG URLs consistently:
import { getURL } from 'one'
type OgParams = {
title: string
type?: 'blog' | 'docs' | 'default'
author?: string
section?: string
}
export function ogUrl(params: OgParams): string {
const base = getURL()
const search = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value)
})
return `${base}/api/og?${search.toString()}`
}Use it in pages:
import { ogUrl } from '~/features/og'
const imageUrl = ogUrl({
title: 'My Blog Post',
type: 'blog',
author: 'Jane Doe',
})Caching
Add cache headers for better performance:
import { ImageResponse } from '@vercel/og'
import { setResponseHeaders } from 'one'
export const GET = async (request: Request) => {
// Cache for 1 day on CDN, 1 week stale-while-revalidate
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=604800')
})
const url = new URL(request.url)
const title = url.searchParams.get('title') || 'One Framework'
return new ImageResponse(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#000',
color: '#fff',
fontSize: 60,
}}
>
{title}
</div>,
{ width: 1200, height: 630 }
)
}Debugging
Test your OG images directly in the browser:
http://localhost:8081/api/og?title=Test+TitleUse the OpenGraph debugger or social platform preview tools to verify how images appear when shared.
Apps Onestack.Dev Data Docs Guides Ota Updates (`apps/onestack.dev/data/docs/guides-ota-updates.mdx`)
title: Over-the-air Updates
Over-the-air (OTA) updates ship JavaScript and asset changes to installed native apps without going through TestFlight or the Play Store.
One.Updates
One ships its own OTA client. Set the update url and runtime version in native.app:
one({
native: {
app: {
// ...
updates: {
url: 'https://updates.example.com',
runtimeVersion: '1.0',
},
},
},
})Prebuild stamps both into the binary. Publish from the app's source and serve the output directory at //<runtimeVersion>/:
one updates publish --platform ios --out ./ota-out
one updates publish --platform android --out ./ota-out-androidThe launcher picks the newest downloaded update for the binary's runtime version before React Native starts, and rolls back when an update fails before first render. See Updates for the client API and publish options.
hot-updater
hot-updater is a self-hosted alternative (Supabase, Cloudflare R2, S3, custom). Install and init:
bun add @hot-updater/react-native react-native-mmkv
bun add -D hot-updater @hot-updater/bare @hot-updater/supabase
npx hot-updater initWrap your app:
import { HotUpdater } from '@hot-updater/react-native'
export default HotUpdater.wrap({
source: process.env.ONE_PUBLIC_HOT_UPDATER_URL,
})(App)Publish:
npx hot-updater deploy -p ios -c production
npx hot-updater deploy -p android -c productionSee hot-updater.dev/docs for full backend setup. Apps on EAS Update follow Coming from Expo.
What's OTA-safe
Only publish OTA updates for changes that don't touch the native binary:
- ✅ JavaScript, TypeScript, JSX, styles, copy, bundled assets
- ❌ native dependencies,
native.appfields that affect native output, any native iOS/Android file
For those, bump runtimeVersion, build a fresh TestFlight/Play binary, and only then publish OTA updates against the new runtime. The runtime version gates delivery so devices on the old binary don't receive incompatible updates.
Owning the config files yourself
If you'd rather commit babel.config.cjs / metro.config.cjs and customize them (custom Metro resolvers, watcher options, etc.), generate them once:
npx one metro-ejectThis writes shim files (delegating to one/babel-preset and one/metro-config) without the @one/generated marker comment. After ejecting:
- The files are yours — edit freely and commit them.
- The CI postinstall hook detects the missing marker and won't touch them.
- You stay in charge of keeping them in sync with future One releases.
A typical ejected metro.config.cjs looks like:
const { withOne } = require('one/metro-config')
const oneBundlerOptions = {
routerRoot: 'app',
}
module.exports = (async () => {
const config = await withOne(__dirname, oneBundlerOptions)
// your customizations here
return config
})()Pass --force to overwrite existing files.
Reference
Apps Onestack.Dev Data Docs Guides Skew Protection (`apps/onestack.dev/data/docs/guides-skew-protection.mdx`)
title: Skew Protection
After a deployment, users with open tabs still have old JavaScript bundles. When they navigate, dynamic imports reference chunk URLs that no longer exist on the server, causing 404s, broken UI, or blank pages.
One has built-in skew protection that detects these failures and recovers automatically.
Configuration
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
skewProtection: true // true (default) | 'proactive' | false
}
})
]
}Error Recovery (default)
With skewProtection: true (the default), One detects chunk load failures and automatically reloads the page. This covers two failure paths:
- Vite preload errors — caught via the
vite:preloadErrorevent when Vite's module preloader fails to fetch a chunk - Dynamic import errors — caught when One's own
dynamicImport()calls fail with browser-specific chunk load error messages (Chrome, Firefox, Safari)
When a failure is detected, the page reloads so the browser fetches fresh HTML with the new asset URLs. A sessionStorage guard prevents infinite reload loops — if a reload already happened within the last 10 seconds, the guard stops further reloads.
No configuration needed. This is always on unless you explicitly set skewProtection: false.
Proactive Mode
one({
web: {
skewProtection: 'proactive'
}
})Proactive mode adds version polling on top of error recovery. Instead of waiting for a chunk to fail, the client periodically checks whether a new version has been deployed and forces a full-page navigation on the next link click.
How it works
- At build time, One emits a
version.jsonfile containing the build's cache key - The client polls
version.jsonevery 2 minutes with cache-busting headers - When the polled version doesn't match the running bundle's version, the app is marked as stale
- On the next client-side navigation, instead of doing an SPA transition, One does a full
window.location.hrefnavigation so the server returns fresh HTML with new assets
This is similar to how SvelteKit handles version skew. The user never sees a broken page — they just get a slightly slower (full-page) navigation that loads the new deployment.
Events
When a version mismatch is detected, One dispatches a one-version-update custom event on window:
window.addEventListener('one-version-update', (e) => {
const version = (e as CustomEvent<{ version?: string }>).detail.version
console.log('new version available:', version)
// show a toast, banner, etc.
})You can use this to show users a "new version available" banner with a manual refresh button instead of waiting for the next navigation.
Window flag
The window.__oneVersionStale flag is set to true when a version mismatch is detected. You can check this from anywhere in your app:
if ((window as any).__oneVersionStale) {
// app has a newer deployment available
}Resilient imports
One also exports the helpers behind this recovery from 'one', for your own lazy boundaries and dynamic imports:
lazyWithRetry(loader, options?): a drop-in replacement forReact.lazy. It retries a transient chunk failure in place before React caches the result, so one failed fetch does not poison the component for the whole session.loadWithRetry(loader, options?): wraps any async loader with retry and backoff. On an unrecoverable chunk-load error it triggers the debounced reload instead of rejecting into a surviving tree.isChunkLoadError(err): returns true when the error matches a browser chunk-load failure message.handleSkewError(): schedules the debounced one-time reload. Returns true when a reload was scheduled, false on the server or when a reload already happened in the last 10 seconds.
import { Suspense } from 'react'
import { handleSkewError, isChunkLoadError, lazyWithRetry, loadWithRetry } from 'one'
// resilient lazy component: retries transient failures, reloads on deploy skew
const SettingsPage = lazyWithRetry(() => import('../components/SettingsPage'))
export function SettingsRoute() {
return (
<Suspense fallback={null}>
<SettingsPage />
</Suspense>
)
}
// resilient data loader with custom retry budget
const stats = await loadWithRetry(() => fetch('/api/stats').then((res) => res.json()), {
attempts: 5,
delayMs: 1000,
})
// manual recovery in your own dynamic import
try {
await import('../components/HeavyPanel')
} catch (err) {
if (isChunkLoadError(err)) handleSkewError()
}Disabling
To turn off skew protection entirely:
one({
web: {
skewProtection: false
}
})This disables both the error recovery reload and the proactive polling. Chunk load failures will be silently swallowed as they were before this feature existed.
CDN Edge Caching
With skew protection enabled, One automatically sets cache headers that allow CDN edge caching for SSG and SPA pages:
cache-control: public, s-maxage=60, stale-while-revalidate=120This means:
- CDN caches pages for 60 seconds — subsequent requests are served from edge, cutting TTFB to ~50ms
- Stale-while-revalidate for 120 seconds — even after 60s, the CDN serves stale content while fetching fresh in the background
- SSR pages are not cached — they return
no-cachesince content is dynamic per request
Why this is safe
Previously, CDN caching HTML was risky: after a deploy, cached HTML references old JS bundles that may not exist, causing 404s and broken pages.
Skew protection solves this. If a cached page tries to load a missing chunk:
- The chunk request 404s
- Skew protection detects the failure via
vite:preloadError - The page auto-reloads, fetching fresh HTML from the origin
- User sees the new deployment
The worst case is a brief flash on reload — much better than a broken page.
Overriding cache headers
If you need custom cache behavior, set headers in your loader or middleware:
import { setResponseHeaders } from 'one'
export async function loader() {
// cache for 5 minutes at the edge
await setResponseHeaders((headers) => {
headers.set('cache-control', 'public, s-maxage=300')
})
return { data: '...' }
}Custom headers take precedence — One only sets defaults when no cache-control header is present.
Apps Onestack.Dev Data Docs Guides Tamagui (`apps/onestack.dev/data/docs/guides-tamagui.mdx`)
title: Tamagui and One
Tamagui and One go together so well you'd swear they were made by the same person. When targeting both native and web, and wanting to share UI, you don't have many options.
Tamagui does many things that no other universal libraries do: all of its features work with SSR without flashing of styles, it supports spring and CSS animations (also SSR safe), it supports light and dark mode (also SSR safe), it's fully type safe, it supports container queries, and it outputs nearly ideal code for each platform.
We're going to support SSR-safe light and dark mode with the @vxrn/color-scheme helpers that one re-exports:
npm add tamagui @tamagui/config @tamagui/vite-pluginBe sure to check out the Dark mode guide for more detail there.
Add your config/tamagui.config.ts:
import { defaultConfig } from '@tamagui/config/v5'
import { animations } from '@tamagui/config/v5-css'
import { createTamagui } from 'tamagui'
export const config = createTamagui({
...defaultConfig,
animations,
})
type Conf = typeof config
declare module 'tamagui' {
interface TamaguiCustomConfig extends Conf {}
}
export default configThe reason we separate this file out is that it lets the Tamagui compiler optimize things. You don't have to do this, but it's pretty easy. The declare module block types your tokens and themes across the app.
This preset config we recommend as it lets you just get working on your app, but if you want a custom configuration, the Takeout starter in npx one has a nice custom configuration example.
Next, update your root _layout.tsx. one re-exports the @vxrn/color-scheme helpers, so SchemeProvider and useUserScheme come straight from one:
import type { ReactNode } from 'react'
import { SchemeProvider, Slot, useUserScheme } from 'one'
import { TamaguiProvider } from 'tamagui'
import config from '../config/tamagui.config'
export default function Layout() {
return (
<SchemeProvider>
<ThemeProvider>
<Slot />
</ThemeProvider>
</SchemeProvider>
)
}
// this is in its own component so it can access the useUserScheme hook
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const userScheme = useUserScheme()
return (
<TamaguiProvider config={config} defaultTheme={userScheme.value}>
{children}
</TamaguiProvider>
)
}Next for the Vite plugin. You don't need to set up the Tamagui Vite plugin, but it adds a lot of nice stuff: debug attributes, broader react-native compatibility, and the optimizing compiler. The compiler runs for every component listed in components; pass disableExtraction: true to turn it off.
import { tamaguiPlugin } from '@tamagui/vite-plugin'
import { one } from 'one/vite'
import type { UserConfig } from 'vite'
export default {
plugins: [
one(),
tamaguiPlugin({
components: ['tamagui'],
config: './config/tamagui.config.ts',
}),
],
} satisfies UserConfigOptimizing further
If you want to optimize things a bit further, we can output the Tamagui design system CSS to a stylesheet by changing our plugin configuration:
import { tamaguiPlugin } from '@tamagui/vite-plugin'
import { one } from 'one/vite'
import type { UserConfig } from 'vite'
export default {
plugins: [
one(),
tamaguiPlugin({
components: ['tamagui'],
config: './config/tamagui.config.ts',
outputCSS: './public/tamagui.css',
}),
],
} satisfies UserConfigAnd then changing our root layout to add disableInjectCSS, and instead importing the CSS at the top:
import '../public/tamagui.css'
import type { ReactNode } from 'react'
import { SchemeProvider, Slot, useUserScheme } from 'one'
import { TamaguiProvider } from 'tamagui'
import config from '../config/tamagui.config'
export default function Layout() {
return (
<SchemeProvider>
<ThemeProvider>
<Slot />
</ThemeProvider>
</SchemeProvider>
)
}
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const userScheme = useUserScheme()
return (
<TamaguiProvider config={config} defaultTheme={userScheme.value} disableInjectCSS>
{children}
</TamaguiProvider>
)
}That should be about it! You now have SSR safe styling, light and dark mode, and a bunch of advanced styling features.
Check out the Tamagui site for more documentation.
Apps Onestack.Dev Data Docs Guides (`apps/onestack.dev/data/docs/guides.mdx`)
title: Guides
Guides to common patterns are here. If you'd like to contribute a Guide, please do! Check with us in the Discord, then open a PR.
Apps Onestack.Dev Data Docs Helpers GetURL (`apps/onestack.dev/data/docs/helpers-getURL.mdx`)
title: getURL
A function that takes no arguments and returns a string of the current URL of the running application on client or server.
import { getURL } from 'one'
const url = getURL()
// Dev: "http://127.0.0.1:8081"
// Prod: Your ONE_SERVER_URL valueFor example, in dev-mode by default this would be a string of http://127.0.0.1:8081. In non-development environments you will need to set process.env.ONE_SERVER_URL to your production URL with no trailing /.
Apps Onestack.Dev Data Docs Helpers Href (`apps/onestack.dev/data/docs/helpers-href.mdx`)
title: href
A simple function allows for creating typed route strings. It's a type-level only check, at runtime it only validates that it is a string.
import { href } from 'one'
const postPageLink = href(`/post/hello-world`) // will type error if invalid HrefThis is useful for ensuring your links are valid routes in your application at compile time.
Apps Onestack.Dev Data Docs Helpers IsResponse (`apps/onestack.dev/data/docs/helpers-isResponse.mdx`)
title: isResponse
One uses Request/Response type objects for API routes, but for some environments doing an instanceof Response can fail.
isResponse takes any value and returns true if it is a Response-like object. Useful for API utility functions.
import { isResponse } from 'one'
export async function handleResult(result: unknown) {
if (isResponse(result)) {
return result
}
return Response.json(result)
}Apps Onestack.Dev Data Docs Helpers Redirect (`apps/onestack.dev/data/docs/helpers-redirect.mdx`)
title: redirect
A function that takes two arguments: a string path, and an optional number status.
import { redirect } from 'one'
export function redirectToLogin() {
return redirect('/login')
}It accepts relative paths by using the getURL helper internally.
On the server (including in loaders) it returns a Response.redirect object. On the client it calls router.navigate and returns void.
Usage in Loaders
Use redirect() in loaders to protect routes. When a user navigates via <Link>, the loader runs on the server and the redirect is detected on the client before the protected page ever renders — no flash of unauthorized content, no data leaks.
import { redirect, useLoader } from 'one'
import type { LoaderProps } from 'one'
export function loader({ request }: LoaderProps) {
const session = 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.
This works for both direct page loads (server returns a 302) and client-side <Link> navigation (server returns a redirect signal that the client intercepts before rendering).
How It Works
During client-side navigation, loaders run on the server and the client fetches the result as a JavaScript module. When a loader returns redirect():
- The server detects the redirect response
- Instead of returning a raw HTTP 302 (which the browser's module loader would silently follow), the server transforms it into a JS module containing redirect metadata
- The client detects the redirect signal before navigating or rendering anything
- The client navigates to the redirect target using
REPLACE— the protected page never appears
This means no sensitive data reaches the client when a redirect occurs. The server only sends back the redirect path.
Related
- Loaders - Data loading for routes
- Authentication - Full authentication guide
Apps Onestack.Dev Data Docs Helpers ServerData (`apps/onestack.dev/data/docs/helpers-serverData.mdx`)
title: setServerData / getServerData
For improving performance of client hydration on the web, you can pass data from the server to the client. Data must be serializable to JSON.
Here's an example of a simple useFetch:
import { setServerData, getServerData } from 'one'
type SafeURLs = `${`https://tamagui.dev` | `http://localhost`}${string}
const useFetch = async (url: SafeURLs) => {
if (process.env.VITE_ENVIRONMENT === 'ssr') {
// on the server, fetch and store during render, then return directly:
// getServerData throws on the server
const data = await fetch(url).then(res => res.json())
setServerData(url, data)
return data
}
// on the client, read the data the server stored
return getServerData(url)
}
export default async (props) => {
const serverData = await useFetch(props.url)
return <div />
}
// can use it in loaders, too
export const loader = async ({ params }) => {
await doSomething()
setServerData(params.id, 'data')
return {}
}setServerData only runs on the server and throws elsewhere. getServerData only runs on the client and throws on the server. Keys can be any string; override the One.ClientData type with declare module 'one' to type them.
Apps Onestack.Dev Data Docs Helpers SetResponseHeaders (`apps/onestack.dev/data/docs/helpers-setResponseHeaders.mdx`)
title: setResponseHeaders
Set HTTP response headers from anywhere during server-side rendering - loaders, components, or middleware. Headers are accumulated and merged onto the final response.
import { setResponseHeaders } from 'one'
await setResponseHeaders((headers) => {
headers.set('X-Custom-Header', 'value')
})The headers parameter is a standard Headers object.
Caching and ISR
Use setResponseHeaders in loaders to enable CDN caching and Incremental Static Regeneration:
import { setResponseHeaders, useLoader } from 'one'
export async function loader({ params }) {
await setResponseHeaders((headers) => {
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
})
const post = await fetchPost(params.slug)
return { post }
}
export default function BlogPost() {
const { post } = useLoader(loader)
return <article>{post.content}</article>
}Common cache header patterns:
s-maxage=3600- CDN caches for 1 hourstale-while-revalidate=86400- Serve stale content while revalidating in background (up to 1 day)max-age=0, must-revalidate- Always revalidate with originprivate, no-store- Never cache (for user-specific data)
Note: stale-while-revalidate requires CDN support (Vercel, CloudFront, Fastly). Cloudflare does not currently support it.
Cookies
Set cookies using the standard Set-Cookie header:
import { setResponseHeaders } from 'one'
export async function POST(request: Request) {
const { token } = await authenticateUser(request)
await setResponseHeaders((headers) => {
headers.append('Set-Cookie', `session=${token}; HttpOnly; Secure; SameSite=Strict; Path=/`)
})
return Response.json({ success: true })
}In Middleware
import { createMiddleware, setResponseHeaders } from 'one'
export default createMiddleware(({ request }) => {
setResponseHeaders((headers) => {
headers.set('X-Request-Id', crypto.randomUUID())
})
})Important Notes
- Only works server-side (SSR routes and loaders)
- Can be called multiple times - headers are merged
- Must be called during the request lifecycle (not in event handlers or timeouts)
Apps Onestack.Dev Data Docs Helpers Utility Functions (`apps/onestack.dev/data/docs/helpers-utility-functions.mdx`)
title: Utility Functions
Small helpers exported directly by one. Each has its own page:
- redirect - navigate on the client, return a redirect response on the server
- getURL - the current app URL on client or server
- isResponse - check for a Response-like value
- href - build typed route strings
- setServerData / getServerData - pass data from the server to the client
- setResponseHeaders - set headers during server rendering
- watchFile - register loader file dependencies for hot reload
Apps Onestack.Dev Data Docs Helpers WatchFile (`apps/onestack.dev/data/docs/helpers-watchFile.mdx`)
title: watchFile
A function that registers a file dependency for hot reload during development. When the file changes, the loader will automatically re-run and refresh data on the client without a full page reload.
import { watchFile } from 'one'
import { readFile } from 'fs/promises'
export async function loader({ params: { slug } }) {
const filePath = `./content/${slug}.mdx`
watchFile(filePath)
const content = await readFile(filePath, 'utf-8')
return { content }
}- No-op in production and on the client
- Pass the same path you use with
fs.readFileor similar - See Loaders - Hot Reload for more details
Apps Onestack.Dev Data Docs Hooks UseActiveParams (`apps/onestack.dev/data/docs/hooks-useActiveParams.mdx`)
title: useActiveParams
Returns URL parameters globally, updating even when the route using it is not focused.
Unlike useParams, this hook updates on every navigation regardless of which route is active. Use this for analytics, background operations, or global UI that needs to react to all route changes.
Basic Usage
import { useActiveParams } from 'one'
export default function AnalyticsTracker() {
const params = useActiveParams()
useEffect(() => {
analytics.track('page_view', params)
}, [params])
return null
}When to Use
Use useActiveParams for:
- Analytics tracking from a root component
- Global breadcrumbs or navigation UI
- Background data sync based on current route
- Debugging route state
Use useParams for:
- Most component use cases
- Avoiding unnecessary re-renders
- Stack navigators where background screens shouldn't update
Comparison
// Stack: /home -> /users/123 -> /users/123/edit
// Current screen: /users/123/edit
// In /users/123 component (background):
useParams() // { id: '123' } - no updates
useActiveParams() // Updates on every navigation
// In /users/123/edit component (focused):
useParams() // { id: '123' }
useActiveParams() // { id: '123' }Example: Global Breadcrumbs
import { useActiveParams, usePathname } from 'one'
function Breadcrumbs() {
const pathname = usePathname()
const params = useActiveParams<{ id?: string }>()
// Always shows current route, even from a layout component
return (
<nav>
<span>{pathname}</span>
{params.id && <span> - ID: {params.id}</span>}
</nav>
)
}Related
- useParams - Focus-aware params (recommended for most uses)
- usePathname - Get just the pathname
Apps Onestack.Dev Data Docs Hooks UseBlocker (`apps/onestack.dev/data/docs/hooks-useBlocker.mdx`)
title: useBlocker
The useBlocker hook allows you to block navigation when certain conditions are met, such as when a user has unsaved changes in a form. This provides a way to prompt users before they accidentally leave a page with unsaved work.
Basic Usage
import { useBlocker } from 'one'
import { useState } from 'react'
export default function EditPage() {
const [isDirty, setIsDirty] = useState(false)
const blocker = useBlocker(isDirty)
return (
<>
<form onChange={() => setIsDirty(true)}>
<input type="text" />
<button type="submit">Save</button>
</form>
{blocker.state === 'blocked' && (
<div className="modal">
<p>You have unsaved changes. Leave anyway?</p>
<button onClick={blocker.reset}>Stay</button>
<button onClick={blocker.proceed}>Leave</button>
</div>
)}
</>
)
}Function-Based Blocking
For more control, pass a function that receives location information:
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
// Only block when leaving the /edit section
return currentLocation.startsWith('/edit') && !nextLocation.startsWith('/edit')
})The function receives:
currentLocation- The current pathname and search paramsnextLocation- Where the user is trying to navigatehistoryAction- The type of navigation ('push','pop', or'replace')
Blocker States
The blocker returns an object with a state property:
`'unblocked'`
Navigation is not blocked. The blocker object has no additional properties.
if (blocker.state === 'unblocked') {
// Normal state, navigation is allowed
}`'blocked'`
Navigation has been blocked and is waiting for user decision.
if (blocker.state === 'blocked') {
blocker.location // The destination the user tried to navigate to
blocker.reset() // Cancel the navigation, stay on current page
blocker.proceed() // Allow the navigation to continue
}`'proceeding'`
The user has chosen to proceed and navigation is in progress.
if (blocker.state === 'proceeding') {
blocker.location // Where we're navigating to
}Platform Behavior
Web
On web, useBlocker intercepts:
- Browser back/forward buttons - Via popstate events
- Programmatic navigation - Via pushState/replaceState interception
- Page close/refresh - Via beforeunload (shows browser's default prompt)
Native
On native platforms, useBlocker uses React Navigation's beforeRemove event. This blocks:
- Back navigation - Hardware back button, swipe gestures, programmatic back
- Screen removal - When the screen would be removed from the stack
Note: Native blocking cannot prevent the app from being closed or backgrounded.
TypeScript Types
import type { Blocker, BlockerFunction, BlockerState } from 'one'
// BlockerState is 'unblocked' | 'blocked' | 'proceeding'
// BlockerFunction signature
type BlockerFunction = (args: {
currentLocation: string
nextLocation: string
historyAction: 'push' | 'pop' | 'replace'
}) => booleanExample: Form with Confirmation Dialog
/* Detailed source-code truncated for AI context efficiency. */Best Practices
- Reset dirty state on save - Clear the blocking condition when the user saves their work
- Provide clear messaging - Tell users what will be lost if they navigate away
- Make actions obvious - Clearly label the "stay" and "leave" options
- Test both platforms - Behavior differs slightly between web and native
Apps Onestack.Dev Data Docs Hooks UseDrawer (`apps/onestack.dev/data/docs/hooks-useDrawer.mdx`)
title: useDrawer
useDrawer returns the full state of the nearest <Drawer> navigator, for building a completely custom sidebar.
<Drawer> already renders headless on web by default (just the focused screen, no sidebar); reach for useDrawer when you want to render the sidebar yourself. See the headless web navigators guide for the full picture.
Drawer ships from one/drawer to keep bundle size down for apps that don't use it. useDrawer ships from one directly.
Usage
Place your component as a child of <Drawer>. It replaces the headless default on web and is ignored on native, so the same layout file works everywhere:
// app/_layout.tsx, works on both platforms
import { Drawer } from 'one/drawer'
import { Link, useDrawer } from 'one'
function WebDrawerLayout() {
const { screens, focused, isOpen, close, toggle } = useDrawer()
return (
<>
<button onClick={toggle}>Menu</button>
{isOpen && (
<nav>
{screens.map((screen) => (
<Link key={screen.key} href={screen.href} onPress={close}>
{screen.options.drawerLabel ?? screen.options.title ?? screen.name}
</Link>
))}
</nav>
)}
{focused.element}
</>
)
}
export default function Layout() {
return (
<Drawer>
<Drawer.Screen name="index" options={{ drawerLabel: 'Home' }} />
<Drawer.Screen name="settings" options={{ drawerLabel: 'Settings' }} />
<WebDrawerLayout />
</Drawer>
)
}Return value
| Property | Type | Description |
|---|---|---|
screens |
ScreenEntry[] |
Every screen in the drawer. |
focused |
ScreenEntry |
The currently focused screen. |
isOpen |
boolean |
Whether the drawer is currently open. |
open() |
() => void |
Open the drawer. |
close() |
() => void |
Close the drawer. |
toggle() |
() => void |
Toggle the drawer. |
ScreenEntry is the same shape useStack returns: key, name, params, href, isFocused, keepMounted, options, element.
Related
Apps Onestack.Dev Data Docs Hooks UseFocusEffect (`apps/onestack.dev/data/docs/hooks-useFocusEffect.mdx`)
title: useFocusEffect
Similar to React useEffect, but only runs when the current route is focused. The effect runs when the screen comes into focus and the cleanup runs when it goes out of focus.
Unlike React Navigation's useFocusEffect which requires wrapping your callback in useCallback, One's version accepts a dependency array as the second argument directly.
Usage
import { useFocusEffect } from 'one'
function Profile({ userId }) {
const [user, setUser] = useState(null)
useFocusEffect(
() => {
const unsubscribe = API.subscribe(userId, (user) => setUser(user))
return () => unsubscribe()
},
[userId]
)
return <ProfileContent user={user} />
}Parameters
| Parameter | Type | Description |
|---|---|---|
effect |
() => void | (() => void) |
The effect function to run. Can optionally return a cleanup function. |
deps |
any[] |
Dependency array, similar to useEffect. The effect re-runs when dependencies change. |
Behavior
- Focus: The effect runs when the screen becomes focused
- Blur: The cleanup function (if returned) runs when the screen loses focus
- Dependencies: The effect also re-runs when dependencies change, but only if the screen is focused
Apps Onestack.Dev Data Docs Hooks UseHeaderHeight (`apps/onestack.dev/data/docs/hooks-useHeaderHeight.mdx`)
title: useHeaderHeight
Returns the height of the navigator header drawn above the current screen, in points, status bar included. Returns 0 where the screen shows no header.
import { ScrollView } from 'react-native'
import { useHeaderHeight } from 'one'
export default function FeedScreen() {
const headerHeight = useHeaderHeight()
return <ScrollView contentContainerStyle={{ paddingTop: headerHeight }} />
}It reads the same header height React Navigation's own headers provide, without importing @react-navigation/elements into your app. Unlike React Navigation's useHeaderHeight, it never throws outside a header.
Apps Onestack.Dev Data Docs Hooks UseIsFocused (`apps/onestack.dev/data/docs/hooks-useIsFocused.mdx`)
title: useIsFocused
Re-export of useIsFocused from React Navigation.
Returns a boolean, true if the current screen is active.
import { useIsFocused } from 'one'
export default function ProfileScreen() {
const isFocused = useIsFocused()
return (
<View>
<Text>{isFocused ? 'Screen is focused' : 'Screen is not focused'}</Text>
</View>
)
}Note: If you need to run side effects when a screen becomes focused rather than just checking focus state, consider using useFocusEffect instead.
Apps Onestack.Dev Data Docs Hooks UseLinkTo (`apps/onestack.dev/data/docs/hooks-useLinkTo.mdx`)
title: useLinkTo
Used to create props that can be passed to a React Native or web component for creating a custom link.
import { useLinkTo } from 'one'
import { Pressable, Text } from 'react-native'
export default function CustomLinkButton() {
const linkProps = useLinkTo({ href: '/profile' })
return (
<Pressable {...linkProps}>
<Text>Go to Profile</Text>
</Pressable>
)
}Parameters
Accepts a single argument of type:
type LinkToProps = {
href: Href
replace?: boolean
}Note that Href is a string that is typed based on your file system routes.
Return Value
Returns an object of type:
type LinkToResult = {
href: string
role: 'link'
onPress: (event?: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent) => void
}Apps Onestack.Dev Data Docs Hooks UseLoader (`apps/onestack.dev/data/docs/hooks-useLoader.mdx`)
title: useLoader
Returns the data from a route loader. The loader must be defined in the same file as the component using it.
Basic Usage
import { useLoader } from 'one'
export function loader() {
return { hello: 'world' }
}
export default function Page() {
const data = useLoader(loader)
return (
<>{data.hello}</>
)
}Automatic Refetching
Loaders automatically refetch when:
- The pathname changes (e.g.,
/home→/about) - Dynamic params change (e.g.,
/user/1→/user/2) - Search params change (e.g.,
/search?q=hello→/search?q=world)
When to Use
Use useLoader when:
- You only need to access the loader data
- Automatic refetching based on route changes is sufficient
- You don't need loading states or manual refetch control
For more advanced use cases like manual refetching, loading states, or accessing refetch from child components, see useLoaderState.
TypeScript
The hook is fully typed based on your loader's return type:
export function loader() {
return {
user: { id: 1, name: 'John' },
posts: [] as Post[]
}
}
export default function Page() {
const data = useLoader(loader)
// TypeScript knows: data.user.name is string
// TypeScript knows: data.posts is Post[]
}Related
- useLoaderState - Advanced loader hook with refetch and state
- useMatches - Access loader data from any matched route
- Loaders - Defining route loaders
- Layout Loaders - Adding loaders to layouts
Apps Onestack.Dev Data Docs Hooks UseLoaderState (`apps/onestack.dev/data/docs/hooks-useLoaderState.mdx`)
title: useLoaderState
A hook that provides manual refetch control and loading state for loaders. It extends the functionality of useLoader by adding the ability to manually refetch data and track loading states.
Basic Usage
useLoaderState can be used in two different ways:
With a loader (replaces useLoader)
When you pass a loader function, you get back the data along with refetch and state:
import { useLoaderState } from 'one'
export function loader({ path }) {
const url = new URL(path, 'http://localhost')
const query = url.searchParams.get('q') || ''
return fetch(`/api/search?q=${query}`).then(r => r.json())
}
export default function SearchPage() {
const { data, refetch, state } = useLoaderState(loader)
return (
<div>
<h1>Search Results</h1>
{state === 'loading' ? (
<Spinner />
) : (
<Results data={data} />
)}
<button onClick={refetch} disabled={state === 'loading'}>
Refresh Results
</button>
</div>
)
}Without a loader (access from anywhere)
When called without arguments, you can access the refetch function and state from any component in the tree:
import { useLoaderState } from 'one'
function RefreshButton() {
const { refetch, state } = useLoaderState()
return (
<button
onClick={refetch}
disabled={state === 'loading'}
>
{state === 'loading' ? 'Refreshing...' : 'Refresh Page'}
</button>
)
}
// This button can be placed anywhere in your component tree
// and will refetch the current route's loaderReturn Values
When called with a loader
const { data, refetch, state } = useLoaderState(loader)data: The data returned by the loader (same asuseLoader)refetch: A function to manually trigger a fresh loader fetchstate: Current loading state - either'idle'or'loading'
When called without a loader
const { refetch, state } = useLoaderState()refetch: A function to manually trigger a fresh loader fetchstate: Current loading state - either'idle'or'loading'
How It Works
All useLoaderState hooks on the same route share state through an internal subscription system. This means:
- When
refetch()is called from any component, the loader cache for that route is cleared - All components subscribed to that route are notified and re-render
- The loader is executed again with fresh data
- All components receive the updated data simultaneously
This architecture enables powerful patterns like having a refresh button in your header that can refetch data for the current page, without needing to pass props down through your component tree.
Common Use Cases
Pull-to-Refresh
function PullToRefreshWrapper({ children }) {
const { refetch, state } = useLoaderState()
return (
<PullToRefresh onRefresh={refetch} refreshing={state === 'loading'}>
{children}
</PullToRefresh>
)
}Polling for Live Data
function useLivePolling(intervalMs = 5000) {
const { refetch, state } = useLoaderState()
useEffect(() => {
const interval = setInterval(() => {
// Only refetch if not already loading
if (state === 'idle') {
refetch()
}
}, intervalMs)
return () => clearInterval(interval)
}, [refetch, state, intervalMs])
}
export default function LiveDashboard() {
const { data } = useLoaderState(loader)
useLivePolling(3000) // Poll every 3 seconds
return <Dashboard data={data} />
}Error Recovery
export default function DataPage() {
const { data, refetch, state } = useLoaderState(loader)
if (data?.error) {
return (
<ErrorBoundary
error={data.error}
onRetry={refetch}
retrying={state === 'loading'}
/>
)
}
return <PageContent data={data} />
}Form Revalidation
function CommentForm({ postId }) {
const { refetch } = useLoaderState()
const handleSubmit = async (formData) => {
await submitComment(postId, formData)
// Refetch the loader to get updated comments
refetch()
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
</form>
)
}Comparison with useLoader
| Feature | useLoader | useLoaderState |
|---|---|---|
| Returns loader data | ✅ | ✅ |
| Manual refetch | ❌ | ✅ |
| Loading state | ❌ | ✅ |
| Can be called without loader | ❌ | ✅ |
| Can be used in child components | ❌ | ✅ |
Use useLoader when you only need the data and automatic refetching is sufficient.
Use useLoaderState when you need:
- Manual refresh capabilities
- Loading indicators
- Error retry mechanisms
- To trigger refetches from child components
TypeScript
The hook is fully typed based on your loader's return type:
export function loader() {
return {
users: [] as User[],
total: 0
}
}
// TypeScript knows the shape of data
const { data, refetch, state } = useLoaderState(loader)
// ^? { users: User[], total: number }
// Without a loader, data is not available
const { refetch, state } = useLoaderState()
// ^? { refetch: () => void, state: 'idle' | 'loading' }
// Note: no 'data' property when called without loaderMigration from useLoader
Migrating from useLoader to useLoaderState is straightforward:
// Before
import { useLoader } from 'one'
export default function Page() {
const data = useLoader(loader)
return <div>{data.content}</div>
}
// After
import { useLoaderState } from 'one'
export default function Page() {
const { data, refetch, state } = useLoaderState(loader)
return (
<div>
{state === 'loading' && <LoadingSpinner />}
{data.content}
<button onClick={refetch}>Refresh</button>
</div>
)
}Working with Different Rendering Modes
useLoaderState works consistently across all rendering modes:
- SPA (
+spa): Loaders execute on the server for each request, refetch triggers client-side fetching - SSR (
+ssr): Initial load on server, refetch executes on client - SSG (
+ssg): Build-time data as initial state, refetch provides fresh client data
The refetch functionality always executes on the client, ensuring consistent behavior regardless of the initial rendering mode.
Loader Timing History
For debugging and performance monitoring, you can access the timing history of loader executions:
import { getLoaderTimingHistory } from 'one'
function DebugPanel() {
const timings = getLoaderTimingHistory()
return (
<div>
{timings.map((entry) => (
<div key={`${entry.path}:${entry.startTime}`}>
<strong>{entry.path}</strong>: {entry.totalTime}ms
{entry.error && <span> (Error: {entry.error})</span>}
</div>
))}
</div>
)
}The LoaderTimingEntry type contains:
type LoaderTimingEntry = {
path: string // Route path (e.g., '/users/123')
startTime: number // Timestamp when loader started
moduleLoadTime?: number // Time to load the loader module
executionTime?: number // Time to execute the loader
totalTime?: number // Total time from start to finish
error?: string // Error message if loader failed
source: 'preload' | 'initial' | 'refetch' // What triggered the load
}This function is useful for:
- Performance monitoring in development
- Debugging slow loaders
- Building loader timing visualizations in devtools
The history is only populated in development mode and stores the last 50 entries to avoid memory growth.
Related
- useLoader - Basic loader data access
- useMatches - Access loader data from any matched route
- Loaders - Defining route loaders
- Layout Loaders - Adding loaders to layouts
Apps Onestack.Dev Data Docs Hooks UseMatches (`apps/onestack.dev/data/docs/hooks-useMatches.mdx`)
title: useMatches
Returns an array of all matched routes from the root layout down to the current page. Each match includes the route's loader data, params, and route ID.
This is useful for:
- Building breadcrumbs from parent routes
- Accessing layout loader data from any component
- Building table of contents from page data in a layout
Basic Usage
loaderData is typed unknown, since a match can come from any route. Give it the type of the loader that produced it:
import { useMatches } from 'one'
type PageData = { title?: string }
function Breadcrumbs() {
const matches = useMatches()
return (
<nav>
{matches.map((match) => (
<a key={match.routeId} href={match.pathname}>
{(match.loaderData as PageData | undefined)?.title ?? match.routeId}
</a>
))}
</nav>
)
}Accessing Layout Data from a Page
A common use case is accessing a layout's loader data from a page component:
import { useMatches } from 'one'
import type { DocsLayoutData } from './_layout'
// in a page component
function DocsPage() {
const matches = useMatches()
// find the docs layout match
const docsLayout = matches.find((m) => m.routeId.includes('docs/_layout'))
const navItems = (docsLayout?.loaderData as DocsLayoutData | undefined)?.navItems
return (
<div>
<Sidebar items={navItems} />
{/* page content */}
</div>
)
}Accessing Page Data from a Layout
Layouts can access their child page's loader data to render things like a table of contents:
import { Slot, useMatches } from 'one'
export async function loader() {
return {
navItems: await fetchDocsNav(),
}
}
export type DocsLayoutData = Awaited<ReturnType<typeof loader>>
type DocsPageData = { headings: { id: string; title: string }[] }
export default function DocsLayout() {
const matches = useMatches()
// the last match is the current page
const pageMatch = matches[matches.length - 1]
const headings = (pageMatch?.loaderData as DocsPageData | undefined)?.headings
return (
<div>
<Sidebar />
<main>
<Slot />
</main>
<TableOfContents headings={headings} />
</div>
)
}RouteMatch Type
Each match in the array has this shape:
type RouteMatch = {
routeId: string // e.g., './docs/_layout.tsx'
pathname: string // e.g., '/docs/intro'
params: Record<string, string | string[]> // URL params like { slug: 'intro' }
loaderData: unknown // data from the route's loader
}Related Hooks
useMatch
Find a specific route's match by its route ID:
import { useMatch } from 'one'
import type { DocsLayoutData } from './docs/_layout'
function Component() {
// find a specific layout's data
const docsMatch = useMatch('./docs/_layout.tsx')
const navItems = (docsMatch?.loaderData as DocsLayoutData | undefined)?.navItems
}Returns undefined if the route is not in the current match list.
usePageMatch
Get the current page's match (the last/deepest match):
import { Head, usePageMatch } from 'one'
type PageMeta = { title?: string; description?: string }
function Layout() {
const pageMatch = usePageMatch()
const { title, description } = (pageMatch?.loaderData as PageMeta | undefined) ?? {}
return (
<Head>
<title>{title}</title>
<meta name="description" content={description} />
</Head>
)
}Server vs Client Behavior
- Server (SSR/SSG): Returns matches computed during the request with fresh loader data
- Client (hydration): Returns the same matches from the server for consistent hydration
- Client (navigation): Returns updated matches - page loader data is fresh, layout loader data is cached from the initial load
Related
- Layout Loaders - Adding loaders to layout files
- useLoader - Basic loader data access
- useLoaderState - Advanced loader with refetch
Apps Onestack.Dev Data Docs Hooks UseNavigation (`apps/onestack.dev/data/docs/hooks-useNavigation.mdx`)
title: useNavigation
Returns the React Navigation object for the current route. This gives you low-level access to the underlying navigation state and methods.
Tip: Prefer
useRouterfor most navigation needs. It provides a simpler, strongly-typed API that's designed specifically for One. UseuseNavigationonly when you need React Navigation-specific features.
Usage
import { useNavigation } from 'one'
function MyComponent() {
const navigation = useNavigation()
// Access navigation state
const isFocused = navigation.isFocused()
// Add event listeners
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
console.log('Screen focused')
})
return unsubscribe
}, [navigation])
return <View />
}Parameters
| Parameter | Type | Description |
|---|---|---|
parent |
string |
Optional. Path to a parent navigator to get its navigation object |
Parent Path
You can access a parent navigator's navigation object by providing a path:
import { useNavigation } from 'one'
function NestedScreen() {
// Get the root navigator's navigation object
const rootNavigation = useNavigation('/(root)')
// Or use relative paths
const parentNavigation = useNavigation('../')
const grandparentNavigation = useNavigation('../../')
return <View />
}Return Value
Returns the React Navigation NavigationProp. Key methods include:
| Method | Description |
|---|---|
navigate(name, params) |
Navigate to a screen |
goBack() |
Go back to the previous screen |
isFocused() |
Check if the screen is focused |
addListener(event, callback) |
Subscribe to navigation events |
setOptions(options) |
Update screen options |
getParent(id) |
Get a parent navigator |
getState() |
Get the navigation state |
dispatch(action) |
Dispatch a navigation action |
Examples
Screen Focus Events
Run code when a screen gains or loses focus:
import { useNavigation } from 'one'
import { useEffect } from 'react'
function ProfileScreen() {
const navigation = useNavigation()
useEffect(() => {
const unsubscribeFocus = navigation.addListener('focus', () => {
// Refresh data when screen comes into focus
fetchUserData()
})
const unsubscribeBlur = navigation.addListener('blur', () => {
// Cleanup when leaving screen
cancelPendingRequests()
})
return () => {
unsubscribeFocus()
unsubscribeBlur()
}
}, [navigation])
return <View />
}Dynamic Screen Options
Update screen options at runtime:
import { useNavigation } from 'one'
import { useEffect } from 'react'
function ChatScreen({ chatName }) {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({
title: chatName,
headerRight: () => <SettingsButton />,
})
}, [navigation, chatName])
return <View />
}Before Remove Event
Prevent navigation (e.g., for unsaved changes):
import { useNavigation } from 'one'
import { useEffect, useState } from 'react'
import { Alert } from 'react-native'
function EditorScreen() {
const navigation = useNavigation()
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!hasUnsavedChanges) return
// Prevent default behavior of leaving the screen
e.preventDefault()
Alert.alert(
'Discard changes?',
'You have unsaved changes. Are you sure you want to leave?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
)
})
return unsubscribe
}, [navigation, hasUnsavedChanges])
return <View />
}Accessing Parent Navigators
Access methods from parent navigators in nested screens:
import { useNavigation } from 'one'
function DetailsScreen() {
// Get the tabs navigator to switch tabs programmatically
const tabsNavigation = useNavigation('/(tabs)')
const goToProfile = () => {
// Jump to the profile tab
tabsNavigation.navigate('profile')
}
return (
<Button onPress={goToProfile}>
Go to Profile Tab
</Button>
)
}Navigation State
Access the current navigation state:
import { useNavigation } from 'one'
function DebugComponent() {
const navigation = useNavigation()
const state = navigation.getState()
console.log('Current route index:', state.index)
console.log('Route history:', state.routes.map(r => r.name))
return <View />
}Navigation Events
Available events you can listen to:
| Event | Description |
|---|---|
focus |
Screen is focused |
blur |
Screen loses focus |
beforeRemove |
Screen is about to be removed (can be prevented) |
state |
Navigation state changes |
When to Use useNavigation vs useRouter
| Use Case | Recommended Hook |
|---|---|
| Navigate to a route | useRouter |
| Go back | useRouter |
| Replace current route | useRouter |
| Listen to focus/blur events | useNavigation |
| Set screen options dynamically | useNavigation |
| Prevent navigation (beforeRemove) | useNavigation |
| Access navigation state | useNavigation |
| Access parent navigator | useNavigation |
TypeScript
Type the navigation object for better autocomplete:
import { useNavigation } from 'one'
import type { NavigationProp } from '@react-navigation/native'
type RootStackParamList = {
Home: undefined
Profile: { userId: string }
Settings: undefined
}
function MyComponent() {
const navigation = useNavigation<NavigationProp<RootStackParamList>>()
// Now fully typed
navigation.navigate('Profile', { userId: '123' })
}Related Hooks
useRootNavigationState
Returns the root navigation state object from the NavigationContainer. This gives you access to the top-level navigation tree.
import { useRootNavigationState } from 'one'
function Component() {
const rootState = useRootNavigationState()
// Access the current route index and history
console.log('Current index:', rootState.index)
console.log('Routes:', rootState.routes.map(r => r.name))
}Use this when you need to access the root-level navigation state directly. For most use cases, useNavigation() is preferable.
Related Pages
- useRouter - Recommended hook for navigation
- useBlocker - Block navigation with a simpler API
- React Navigation docs - Full navigation prop reference
Apps Onestack.Dev Data Docs Hooks UseParams (`apps/onestack.dev/data/docs/hooks-useParams.mdx`)
title: useParams
Returns URL parameters for the focused route, including dynamic segments and query string values.
On native, this hook reads the mounted screen's own route parameters, including inherited layout parameters. Retained screens keep their own dynamic values when another screen becomes active. On web, dynamic parameters follow the current URL. Use useActiveParams to read the active route globally.
Basic Usage
import { useParams } from 'one'
// Route: app/posts/[id].tsx
// URL: /posts/123?sort=date
export default function PostPage() {
const { id, sort } = useParams()
// id = '123', sort = 'date'
return <Text>Post {id}, sorted by {sort}</Text>
}TypeScript
Using `createRoute` (Recommended)
The best way to get fully typed params is with the createRoute helper:
import { createRoute } from 'one'
const route = createRoute<'/posts/[id]'>()
export default function PostPage() {
const { id } = route.useParams()
// id is automatically typed as string!
return <Text>Post {id}</Text>
}Enable auto-generation in your vite config to have this inserted automatically:
// vite.config.ts
one({
router: {
experimental: {
typedRoutesGeneration: 'runtime', // auto-inserts createRoute
},
},
})See Typed Routes for full setup details.
Manual Typing
You can also type params manually:
export default function PostPage() {
const { id, sort } = useParams<{
id: string
sort?: 'date' | 'title'
}>()
return <Text>Post {id}</Text>
}Catch-All Routes
For catch-all routes like [...slug].tsx, params are returned as arrays:
// Route: app/docs/[...slug].tsx
// URL: /docs/api/hooks/useParams
export default function DocsPage() {
const { slug } = useParams<{ slug: string[] }>()
// slug = ['api', 'hooks', 'useParams']
return <Text>{slug.join(' / ')}</Text>
}URL Decoding
Parameters are automatically URL-decoded:
// URL: /search?q=hello%20world
const { q } = useParams()
// q = 'hello world' (decoded)useParams vs useActiveParams
| Hook | Updates when... | Best for |
|---|---|---|
useParams |
Route is focused | Most components |
useActiveParams |
Any route changes | Analytics, global UI |
In a stack navigator, if you push a new screen, useParams in the previous screen won't update. Use useActiveParams if you need global parameter updates.
Related
- useActiveParams - Global params that update even when unfocused
- usePathname - Get just the pathname
- useSegments - Get route segments as array
Apps Onestack.Dev Data Docs Hooks UsePathname (`apps/onestack.dev/data/docs/hooks-usePathname.mdx`)
title: usePathname
Returns the current pathname without query parameters or hash.
import { usePathname } from 'one'
export default function CurrentPath() {
const pathname = usePathname()
// URL: /blog/hello-world?ref=twitter#comments
// Returns: "/blog/hello-world"
return <Text>Current path: {pathname}</Text>
}Common Uses
Active Link Styling
import { usePathname, Link } from 'one'
function NavLink({ href, children }) {
const pathname = usePathname()
const isActive = pathname === href
return (
<Link
href={href}
style={{ fontWeight: isActive ? 'bold' : 'normal' }}
>
{children}
</Link>
)
}Conditional Rendering
function Layout({ children }) {
const pathname = usePathname()
const showSidebar = !pathname.startsWith('/auth')
return (
<div>
{showSidebar && <Sidebar />}
{children}
</div>
)
}Analytics
function PageTracker() {
const pathname = usePathname()
useEffect(() => {
analytics.trackPageView(pathname)
}, [pathname])
return null
}What's Not Included
The pathname does not include:
- Query parameters (
?foo=bar) - Hash fragments (
#section)
For URL parameters, use useParams or useActiveParams.
Related
- useParams - Get URL parameters
- useSegments - Get pathname as segments array
- useActiveParams - Global URL parameters
Apps Onestack.Dev Data Docs Hooks UseRouter (`apps/onestack.dev/data/docs/hooks-useRouter.mdx`)
title: useRouter
Returns a static object used for routing. This hook will never update, and is only used for imperatively controlling the router.
It takes no arguments and returns object of type:
type Router = {
/** Go back in the history. */
back: () => void
/** If there's history that supports invoking the `back` function. */
canGoBack: () => boolean
/** Navigate to the provided href using a push operation if possible. */
push: (href: Href, options?: LinkToOptions) => void
/** Navigate to the provided href. */
navigate: (href: Href, options?: LinkToOptions) => void
/** Navigate to route without appending to the history. */
replace: (href: Href, options?: LinkToOptions) => void
/** Navigate to the provided href using a push operation if possible. */
dismiss: (count?: number) => void
/** Navigate to first screen within the lowest stack. */
dismissAll: () => void
/** If there's history that supports invoking the `dismiss` and `dismissAll` function. */
canDismiss: () => boolean
/** Update the current route query params. */
setParams: <T = ''>(
params?: T extends '' ? Record<string, string | undefined | null> : InputRouteParams<T>
) => void
/** Subscribe to state updates from the router */
subscribe: (listener: RootStateListener) => () => void
/** Subscribe to loading state updates */
onLoadState: (listener: LoadingStateListener) => () => void
}
type LinkToOptions = {
/** Disable scroll restoration on navigation */
scroll?: boolean
/** Display a different URL in the browser (web only) */
mask?: {
href: Href
}
}Route Masking
You can display a different URL in the browser address bar while navigating to a specific route.
This is useful for modals, side panels, or keeping URLs clean:
const router = useRouter()
// Navigate to /photos/5/modal but show /photos/5 in the URL bar
router.push('/photos/5/modal', {
mask: { href: '/photos/5' }
})When users click back/forward, the actual route state is restored from history.
This feature is web-only.
Related Hooks
useNavigationContainerRef
Returns a ref to the NavigationContainer component. This allows direct access to the React Navigation container.
import { useNavigationContainerRef } from 'one'
function Component() {
const navigationContainerRef = useNavigationContainerRef()
// The ref.current is null until the NavigationContainer mounts
if (navigationContainerRef.current) {
// Access React Navigation API
const state = navigationContainerRef.current.getRootState()
}
}This is primarily useful for advanced React Navigation integration. Most use cases should use useRouter() instead.
useUnstableGlobalHref
Returns the current global pathname with query parameters attached. This is an internal API and may change.
import { useUnstableGlobalHref } from 'one'
function Component() {
const href = useUnstableGlobalHref()
// e.g., '/docs/routing?tab=settings'
}Apps Onestack.Dev Data Docs Hooks UseScrollGroup (`apps/onestack.dev/data/docs/hooks-useScrollGroup.mdx`)
title: useScrollGroup
Register the current route as a scroll group. Child routes will preserve scroll position when navigating between them.
This is useful for layouts where you want to maintain scroll position while users navigate between child routes, such as:
- Tab layouts where switching tabs shouldn't reset the parent scroll
- Dashboard layouts with multiple sections
- Any nested layout that wants independent scroll restoration
Usage
Use this hook in a layout component to create a scroll group for all its children:
// app/dashboard/_layout.tsx
import { useScrollGroup, Slot } from 'one'
export default function DashboardLayout() {
// All routes under /dashboard will share scroll position
useScrollGroup()
return (
<div>
<nav>Dashboard Navigation</nav>
<Slot />
</div>
)
}When users navigate between /dashboard/overview, /dashboard/settings, and /dashboard/reports, the scroll position of the dashboard layout will be preserved.
Custom Group Path
By default, useScrollGroup uses the current pathname. You can specify a custom group path:
// Create a scroll group for a specific path prefix
useScrollGroup('/products')Programmatic Scroll Groups
You can also specify a scroll group when navigating programmatically:
import { useRouter } from 'one'
function MyComponent() {
const router = useRouter()
const goToSettings = () => {
router.navigate('/dashboard/settings', {
scrollGroup: '/dashboard'
})
}
return <button onClick={goToSettings}>Go to Settings</button>
}How It Works
- When a layout calls
useScrollGroup(), it registers the current path as a scroll group - When navigating, if both the previous and new routes are within the same scroll group, the scroll position is preserved
- If navigating to a route outside the scroll group, scroll resets to the top
Platform Support
This hook only affects web scroll behavior. On native platforms, it's a no-op since React Navigation handles scroll restoration differently.
Type
function useScrollGroup(groupPath?: string): voidParameters
| Parameter | Type | Description |
|---|---|---|
groupPath |
string (optional) |
Custom path to use as the scroll group. Defaults to current pathname. |
Apps Onestack.Dev Data Docs Hooks UseSearchParams (`apps/onestack.dev/data/docs/hooks-useSearchParams.mdx`)
title: useSearchParams
Returns URL search parameters as a read-only URLSearchParams object. This provides the standard web API for working with query strings.
Basic Usage
import { useSearchParams } from 'one'
// URL: /products?sort=price&category=electronics
export default function Products() {
const searchParams = useSearchParams()
const sort = searchParams.get('sort') // 'price'
const category = searchParams.get('category') // 'electronics'
const missing = searchParams.get('missing') // null
return <Text>Sorted by {sort}</Text>
}URLSearchParams Methods
The returned object supports all standard URLSearchParams read methods:
const searchParams = useSearchParams()
// Get a single value
searchParams.get('key') // string | null
// Check if param exists
searchParams.has('key') // boolean
// Get all values for a key (for repeated params)
searchParams.getAll('tag') // string[]
// Iterate over all entries
for (const [key, value] of searchParams) {
console.log(key, value)
}
// Convert to string
searchParams.toString() // 'key=value&other=123'Repeated Parameters
Use getAll() when a parameter appears multiple times:
// URL: /filter?tag=react&tag=typescript&tag=native
const searchParams = useSearchParams()
const tags = searchParams.getAll('tag')
// tags = ['react', 'typescript', 'native']Global Option
By default, useSearchParams only updates when the current route is focused. Pass { global: true } to update on any route change:
// Updates even when this screen is not focused
const searchParams = useSearchParams({ global: true })Read-Only
The returned URLSearchParams is read-only. Calling set(), append(), or delete() will throw an error:
const searchParams = useSearchParams()
// ❌ These will throw
searchParams.set('key', 'value')
searchParams.append('key', 'value')
searchParams.delete('key')To update search params, use the router:
import { useRouter, useSearchParams } from 'one'
function Filters() {
const router = useRouter()
const searchParams = useSearchParams()
const updateSort = (sort: string) => {
// Build new search string
const params = new URLSearchParams(searchParams)
params.set('sort', sort)
router.push(`/products?${params.toString()}`)
}
}useSearchParams vs useParams
| Hook | Returns | Use for |
|---|---|---|
useParams |
Plain object | Quick access to params as object |
useSearchParams |
URLSearchParams | Standard web API, repeated params |
Both hooks include path params and query params. Choose based on which API you prefer:
// Object style
const { sort, page } = useParams()
// URLSearchParams style
const searchParams = useSearchParams()
const sort = searchParams.get('sort')
const page = searchParams.get('page')Expo Router Compatibility
For easier migration from Expo Router, the following aliases are available (but deprecated):
useGlobalSearchParams- Alias touseActiveParams(). Returns params that update even when the route is unfocused.useLocalSearchParams- Alias touseParams(). Returns focus-aware params.
Prefer the non-aliased names in new code.
Related
- useParams - Get params as a plain object
- useActiveParams - Global params that update even when unfocused
- usePathname - Get just the pathname
Apps Onestack.Dev Data Docs Hooks UseSegments (`apps/onestack.dev/data/docs/hooks-useSegments.mdx`)
title: useSegments
Returns the current route as an array of path segments. Segments match the file path structure, not the resolved URL values.
import { useSegments } from 'one'
// File: app/users/[id]/settings.tsx
// URL: /users/123/settings
export default function SettingsPage() {
const segments = useSegments()
// Returns: ['users', '[id]', 'settings']
// Note: Returns '[id]' not '123'
return <Text>Route: /{segments.join('/')}</Text>
}When to Use
Use useSegments when you need to know which route file is active, rather than the resolved URL:
- Route matching - Check which section of the app is active
- Navigation guards - Protect routes based on segments
- Breadcrumbs - Build navigation from route structure
For resolved URL values, use usePathname or useParams.
TypeScript
You can strictly type segments for better type safety:
// Given file structure:
// app/
// settings.tsx
// [user]/
// index.tsx
// followers.tsx
type AppSegments =
| ['settings']
| ['[user]']
| ['[user]', 'followers']
function RouteHandler() {
const segments = useSegments<AppSegments>()
if (segments[0] === 'settings') {
return <SettingsPage />
}
if (segments[0] === '[user]') {
if (segments[1] === 'followers') {
return <FollowersPage />
}
return <UserPage />
}
}Examples
Auth Guard
function AuthGuard({ children }) {
const segments = useSegments()
const { user } = useAuth()
const inAuthGroup = segments[0] === '(auth)'
useEffect(() => {
if (!user && !inAuthGroup) {
router.replace('/login')
} else if (user && inAuthGroup) {
router.replace('/')
}
}, [user, inAuthGroup])
return children
}Tab Highlighting
function TabBar() {
const segments = useSegments()
const activeTab = segments[0]
return (
<div>
<Tab active={activeTab === 'home'} href="/home">Home</Tab>
<Tab active={activeTab === 'search'} href="/search">Search</Tab>
<Tab active={activeTab === 'profile'} href="/profile">Profile</Tab>
</div>
)
}Segments vs Pathname
| Hook | Returns | Use Case |
|---|---|---|
useSegments() |
['users', '[id]'] |
Route matching, guards |
usePathname() |
'/users/123' |
Display, analytics |
Related
- usePathname - Resolved pathname string
- useParams - Dynamic segment values
Apps Onestack.Dev Data Docs Hooks UseServerHeadInsertion (`apps/onestack.dev/data/docs/hooks-useServerHeadInsertion.mdx`)
title: useServerHeadInsertion
Register elements to be inserted into the document <head> during server rendering. This hook is server-only and is used for adding custom head content from anywhere in your component tree.
Usage
import { useServerHeadInsertion } from 'one'
function CustomHeadContent() {
useServerHeadInsertion(() => (
<>
<meta name="custom-tag" content="my-value" />
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" />
</>
))
return <div>Page content</div>
}When to Use
Use useServerHeadInsertion when you need to:
- Add head elements from a nested component (not just from the page root)
- Conditionally insert head elements based on component state
- Keep head content close to the component that needs it
For static head content or when you have a clear layout, prefer the <Head> component.
Server-Only
This hook only runs on the server during SSR/SSG. On the client, it has no effect. One calls every registered callback after the page finishes rendering and adds the markup to <head>, so a callback can return what the tree collected during render, such as CSS-in-JS styles.
function MyComponent() {
useServerHeadInsertion(() => (
<title>Dynamic Title</title>
))
// This hook runs on server, but not on client
return <div>Content</div>
}Callback Function
The callback receives no parameters and should return a React element or fragment containing the head elements:
useServerHeadInsertion(() => (
<>
<title>Page Title</title>
<meta name="description" content="Page description" />
<meta property="og:title" content="My Page" />
</>
))Type
The hook accepts a callback that returns a ServerHeadInsertionCallback:
type ServerHeadInsertionCallback = () => React.ReactElementRelated
<Head />- Component for inserting head elements- Dynamic OpenGraph Images - Generate dynamic OG images
Apps Onestack.Dev Data Docs Hooks UseSitemap (`apps/onestack.dev/data/docs/hooks-useSitemap.mdx`)
title: useSitemap
Returns the full route tree of your app as a structured object. Useful for debug screens, route picker UIs, and generating link previews.
import { useSitemap } from 'one'
export default function Sitemap() {
const sitemap = useSitemap()
if (!sitemap) return null
return <SitemapNode node={sitemap} />
}
function SitemapNode({ node }) {
return (
<View>
<Text>{node.filename} → {node.href}</Text>
{node.children.map((child) => (
<SitemapNode key={child.contextKey} node={child} />
))}
</View>
)
}Return Value
useSitemap() returns a recursive SitemapType rooted at the top-level layout, or null if the route tree hasn't been initialized yet (briefly, on first render).
type SitemapType = {
contextKey: string // module key for the route file (e.g. './(app)/_layout.tsx')
filename: string // human-readable filename (e.g. '(app)/_layout')
href: string // route pattern (e.g. '/(app)/thread/[id]')
isInitial: boolean // true if this is the initial route of its layout
isInternal: boolean // true for One-internal routes
isGenerated: boolean // true for in-memory routes (not user files)
children: SitemapType[] // nested routes
}href is the route pattern with dynamic segments left as [id] / [...slug], not a concrete URL. Fill in your own params if you want clickable links:
const docsHref = node.href.replace('[...slug]', 'getting-started/install')Filtering Internal/Generated Routes
User-defined routes have isInternal === false and isGenerated === false. Skip the rest in your UI to avoid showing One's internals:
function visibleChildren(node: SitemapType) {
return node.children.filter((child) => !child.isInternal && !child.isGenerated)
}Related
Apps Onestack.Dev Data Docs Hooks UseStack (`apps/onestack.dev/data/docs/hooks-useStack.mdx`)
title: useStack
useStack returns the full state of the nearest <Stack> navigator on web, for building a completely custom stack layout - your own header, transitions, back button, anything.
<Stack> already renders headless on web by default (just the focused screen); reach for useStack when you want to render something yourself instead. See the headless web navigators guide for the full picture.
Usage
Place your component as a child of <Stack>. It replaces the headless default on web and is ignored on native, so the same layout file works everywhere:
// app/_layout.tsx, works on both platforms
import { Stack, useRouter, useStack } from 'one'
function WebStackLayout() {
const { screens, focused } = useStack()
const router = useRouter()
return (
<>
{screens.length > 1 && <button onClick={router.back}>← Back</button>}
<h1>{focused.options.title}</h1>
{focused.element}
</>
)
}
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Feed' }} />
<Stack.Screen name="[id]" options={{ title: 'Post' }} />
<WebStackLayout />
</Stack>
)
}Return value
| Property | Type | Description |
|---|---|---|
screens |
ScreenEntry[] |
Every screen currently in the stack, in stack order. |
focused |
ScreenEntry |
The currently focused screen. |
useStack is a state reader. To navigate, use useRouter / router and Link as you would anywhere else in the app - every entry carries an href.
`ScreenEntry`
| Property | Type | Description |
|---|---|---|
key |
string |
Unique per-instance key. |
name |
string |
The route name, matching the file name in app/. |
params |
object |
The route's params. |
href |
string |
The resolved path for this screen. |
isFocused |
boolean |
Whether this is the focused screen. |
keepMounted |
boolean |
Whether options.keepMounted was set. |
options |
object |
The compiled options for this screen (title, presentation, and anything else set via Stack.Screen). |
element |
ReactElement |
The screen's rendered content. Rendering it mounts the route. |
Presentation screens
Screens with a presentation option (sheet, modal, formSheet, ...) still show up in screens alongside regular pushed screens - useStack doesn't separate them out. If your custom layout needs to treat them differently, check focused.options.presentation. Most apps handle presentation screens with Presentations instead, which is scoped specifically to them.
Related
Apps Onestack.Dev Data Docs Hooks UseTabs (`apps/onestack.dev/data/docs/hooks-useTabs.mdx`)
title: useTabs
useTabs returns the full state of the nearest <Tabs> navigator, for building a completely custom tab bar.
<Tabs> already renders headless on web by default (just the focused tab, no bar); reach for useTabs when you want to render the tab bar yourself. See the headless web navigators guide for the full picture.
Usage
Place your component as a child of <Tabs>. It replaces the headless default on web and is ignored on native, so the same layout file works everywhere:
// app/(tabs)/_layout.tsx, works on both platforms
import { Link, Tabs, useTabs } from 'one'
function WebTabBar() {
const { screens, focused } = useTabs()
return (
<>
<nav>
{screens.map((tab) => (
<Link
key={tab.name}
href={tab.href}
className={tab.isFocused ? 'active' : undefined}
>
{tab.options.title}
</Link>
))}
</nav>
{focused.element}
</>
)
}
export default function Layout() {
return (
<Tabs>
<Tabs.Screen name="home" options={{ title: 'Home' }} />
<Tabs.Screen name="feed" options={{ title: 'Feed' }} />
<WebTabBar />
</Tabs>
)
}Return value
| Property | Type | Description |
|---|---|---|
screens |
ScreenEntry[] |
Every tab, in the order they were declared. |
focused |
ScreenEntry |
The currently focused tab. |
useTabs is a state reader. To switch tabs, link to the tab's href with Link or router.navigate(href).
ScreenEntry is the same shape useStack returns: key, name, params, href, isFocused, keepMounted, options, element.
Keeping inactive tabs mounted
Set keepMounted on a screen and the default headless view keeps it alive after you switch away, hidden via React 19.2's <Activity>, so scroll position and form state survive. A tab is only kept once it has actually been focused, matching react-navigation's lazy behavior on native.
<Tabs.Screen name="feed" options={{ title: 'Feed', keepMounted: true }} />A custom layout renders whatever it wants, so it owns this itself. keepMounted is on every entry if you want to honor it the same way.
Related
Apps Onestack.Dev Data Docs Installation (`apps/onestack.dev/data/docs/installation.mdx`)
title: Installation
Getting started with One is as easy as:
npx oneThe CLI will guide you through the process of bootstrapping one of our starter templates. See the CLI reference for every command.
If this is your first One app, we recommend taking this route, as you can choose between bare-bones example all the way up to a full stack with a user system and authentication. We will be launching more starters as the project matures.
npx skills add onejs/skills Full list and per-agent install instructions: github.com/onejs/skills.
5-minute tutorial
While we recommend using a preset to get started, it's helpful to walk through creating a new One app from scratch.
For those of you who prefer to learn by doing, we've put together a 5-minute tutorial that will have you building a basic app in no time.
Let's begin with our package.json:
{
"name": "one-app",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "one dev",
"build": "one build",
"serve": "one serve",
"prebuild:native": "one prebuild",
"ios": "one run:ios",
"android": "one run:android"
},
"dependencies": {
"one": "latest",
"react": "19.3.0",
"react-native": "0.88.0",
"react-native-web": "^0.21.2",
"@react-navigation/native": "8.0.0-alpha.51",
"react-native-screens": "~4.30.0",
"react-native-reanimated": "~4.7.1",
"react-native-worklets": "~0.13.0"
},
"devDependencies": {
"@react-native-community/template": "^0.88.0",
"vite": "^8"
}
}It is strongly recommended to pin the one version for production stability:
# npm
npm install one@latest --save-exact
# yarn
yarn add one@latest --exact
# pnpm
pnpm add one@latest --save-exact
# bun
bun add one@latest --exactThis ensures consistent behavior across different environments and deployments.
If using pnpm or a package manager that symlinks dependencies, you'll want to disable that setting.
One has (just a few) patches that fix dependencies. This is because the React Native ecosystem has many weirdly published packages (think JSX or Flowtype published in .js files, or non-standard legacy babel experimental transforms, etc). We are working with package authors to improve this, but we've found patches to be more reliable, simpler and less mysterious than other solutions.
Vite Config
Next, let's add our ultra-simple vite.config.ts:
import type { UserConfig } from 'vite'
import { one } from 'one/vite'
export default {
plugins: [
one({
native: {
app: {
name: 'OneApp',
ios: { bundleId: 'com.example.oneapp' },
android: { applicationId: 'com.example.oneapp' },
},
},
}),
],
} satisfies UserConfigTSConfig
One will create a TSConfig for you when you first start the dev server, with some defaults that work well. Of course you can create your own if you'd prefer. The default looks like this:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./*"]
},
"strict": true,
"rootDir": ".",
"module": "Preserve",
"moduleResolution": "Bundler",
"preserveSymlinks": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"noImplicitAny": false,
"types": ["node", "react", "vite/client"],
"lib": ["dom", "esnext"]
},
"exclude": ["node_modules", "**/test", "**/dist", "**/types", "**/__tests__"]
}For TypeScript types on One's preset environment variables, add /// <reference types="one/env" /> to any .d.ts file in your project.
File System Routes
One expects your file system routes to be placed in an app directory.
app/_layout.tsx serves as the top-level wrapper for all routes, ideal for:
- Consistent page structure
- Global styles or themes
- Persistent navigation
- App-wide state management
One uses the <Slot /> component to indicate where child components should render, allowing for nested layouts and fine-grained control over your UI structure.
Let's create a root layout app/_layout.tsx:
import { Slot } from 'one'
export default function Layout() {
return <Slot />
}And then create a simple index page app/index.tsx:
import { View, Text } from 'react-native'
export default function MyApp() {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight: '100%',
}}
>
<Text>Hello from One</Text>
</View>
)
}You can now install your node modules and run your app. 🎉
bun install && bun run devRunning your app
If the port isn't taken, you should be able to load http://localhost:8081 and to see your website (use --port to change the port).
For native, generate the React Native projects once and launch the platform you want:
bun run prebuild:native
bun run ios
# or
bun run androidOne's default native path uses its Vite and Rolldown pipeline and does not require Expo or
a Babel config.
Note that for web-only projects you don't need react-native, and further note that if you want something that has a much broader styling featureset and much lighter bundle-size on web, you can use Tamagui, which has no dependency on React Native.
Apps Onestack.Dev Data Docs Introduction (`apps/onestack.dev/data/docs/introduction.mdx`)
title: Introduction
One lets you target both React Native and web with a single codebase, replacing both Metro and a separate web framework with a single framework, with shared file-system routing. One runs on Vite, which brings many benefits.
For native builds, One uses Rolldown, the same bundler as web.
Highlights
- Target native and web at once with typed file system routes.
- Loaders ala Remix that tree-shake away on the client.
- Render modes on a per-page basis (SSG, SSR, SPA, and API).
- Preloads all JS for initial load and when hovering links, without need for a route map.
- Includes a production server out of the box.
Use Cases
One works well as:
- A uniquely good way to ship cross-platform apps with shared code.
- A light, fast and opinionated framework for web-only or native-only.
For a few more answers to common questions, see our FAQ.
Our Full-Stack Philosophy
One lets you customize your stack as you please but recommends Zero as the first-class solution to data. We think the client-side "sync engine" model it uses is a clear win-win for simpler code and better UX.
Today, we have a starter with npx one that sets up an early alpha of Zero, while we work on a more official solution that neatly integrates One and Zero out of the box.
Acknowledgements
One started as an experiment in porting Expo Router to our vxrn library. We since changed much of the internals and added render modes and a variety of features. We owe the Expo team a huge thanks for their pioneering efforts.
One was built thanks to some amazing open source technologies and contributors:
- React Navigation provides the base routing library.
- Expo Router for large parts of the initial One router.
- Software Mansion for React Native Screens and React Native Gesture Handler.
- Remix for inspiration for the
loaderAPIs. - Vite, of course.
And a special thanks to the following individuals:
- Matias Capeletto for inviting us to speak at ViteConf, giving the initial spark.
- Fatih Aygün for freely helping at length, getting us past many hurdles.
- Hiroshi Ogawa for his expert consulting for which One wouldn't exist without.
- Dan Maier, for graciously gifting us the
onenpm package.
Apps Onestack.Dev Data Docs Metro Mode (`apps/onestack.dev/data/docs/metro-mode.mdx`)
title: Metro Mode
Overview
One uses Rolldown for native builds by default. Metro is available when you select it explicitly.
Enabling Metro Mode
Configure the native bundler to Metro in your vite.config.ts:
Metro is not part of One's default install. Add One's Metro plugin and the Expo Babel preset to the app first:
bun add -d @vxrn/vite-plugin-metro babel-preset-expoOne reports an installation error naming the missing package when Metro starts without them. The default Rolldown path loads neither.
import type { UserConfig } from 'vite'
import { one } from 'one/vite'
export default {
plugins: [
one({
native: {
// Enable Metro bundler for native builds
bundler: 'metro',
},
}),
],
} satisfies UserConfigLimitations
When using Metro mode, only some of the Vite features are supported. One provides only a subset of Vite functionality in Metro mode to maintain compatibility and stability.
We are actively working on expanding Vite feature support, but for any features we don't yet provide through Vite configuration, you can always use the standard React Native way to change the configuration using metro.config.js.
Only the following Vite features and configurations are supported in Metro mode for now:
import.meta.envBuilt-in Constants: supported but may not work as expected for web specific variables. import.meta.envEnv Variablestsconfig-paths: As one automatically configures vite-tsconfig-pathsto support path mappings defined intsconfig.jsonfor your project, the same applies to Metro mode. However, if you are using thevite-tsconfig-pathsplugin manually, the custom options you provide there will not be applied for Metro.
Configuration
By default, One starts from React Native's Metro configuration and applies the same One router, environment, and server transforms used by the native Rolldown path.
Like ordinary React Native apps, you can still configure Metro using metro.config.js for more advanced settings, such as resolver or watcher options:
const { withOne } = require('one/metro-config')
module.exports = withOne(__dirname)metro.config.js works exactly as it would in a standard React Native project, giving you full control over the bundling process.
No Babel
By default, One uses its own Metro worker built around Oxc. The worker combines Oxc's Rust parser and transforms with TypeScript implementations of One's native transforms, Hermes parsing for Flow, and esbuild for syntax lowering. This pipeline runs without Babel.
A Babel plugin One has no port for would silently do nothing, so instead the build fails and names it. You have three options:
- Port it and list it in
native.bundlerOptions.nativeTransformModules. Each entry is a module id (Metro runs transforms in separate worker processes, so it has to be serializable) that default-exports(code, ctx) => string | null. Your transforms run first, on the original source, with an absolutectx.filename. - Remove it from
native.bundlerOptions.babelConfigOverrides. - Set
native.bundlerOptions.nativeTransforms: falseto use Babel, orONE_METRO_NATIVE_TRANSFORMS=0for a single run. Install@react-native/babel-presetand the Babel packages One's preset uses (bun add -d @react-native/babel-preset @babel/core @babel/generator @babel/parser @babel/plugin-transform-export-namespace-from @babel/traverse @babel/types babel-dead-code-elimination babel-plugin-module-resolver), then runone metro-ejectto create a Babel config that delegates toone/babel-preset, which builds on React Native's preset. Expo presets are optional app configuration and are never loaded by One.
A native transform receives source text and returns updated source text. It can use Oxc or another tool suited to the transformation:
module.exports = function myTransform(code, ctx) {
// ctx: { filename, platform, dev, projectRoot }
if (!code.includes('__BUILD_ID')) return null
return code.replace(/__BUILD_ID/g, JSON.stringify(process.env.BUILD_ID))
}one({
native: {
bundler: 'metro',
bundlerOptions: {
nativeTransformModules: ['./scripts/my-transform.cjs'],
},
},
})Return null to leave the file alone. Transforms run in order, before One's built-in transforms. The first receives the original file contents; each subsequent transform receives the previous transform's output. A transform that records original source positions should run first.
Lazy Startup
By default, Metro starts as soon as the Vite dev server is ready. If you're primarily doing web development but have Metro configured, you can defer Metro startup until a native client actually connects:
import type { UserConfig } from 'vite'
import { one } from 'one/vite'
export default {
plugins: [
one({
native: {
bundler: 'metro',
bundlerOptions: {
startup: 'lazy',
},
},
}),
],
} satisfies UserConfigOr via environment variable:
ONE_METRO_LAZY=1 one devWith lazy startup:
- Metro won't start when you run
one dev - When a simulator or device connects and checks
/status, it gets an immediate response while Metro starts in the background - The first bundle request waits for Metro to finish initializing
This can significantly speed up dev server startup when you don't always need native builds.
Performance
Metro can use more mature React Native tooling. One's default Rolldown path remains the recommended choice for new apps. Both native bundlers use Oxc by default; Metro can opt back into Babel.
Apps Onestack.Dev Data Docs Native Apis (`apps/onestack.dev/data/docs/native-apis.mdx`)
title: Native Platform APIs
Service namespaces live on One, including services with platform-specific
operations. The camera view and widget layout helpers below use One.iOS.
Hooks are named imports from one. Use each reference for the complete
signature, configuration, permission behavior, event cleanup, and errors.
Shared services
| API | What you can do |
|---|---|
| One.Notifications | Request permission, schedule and dismiss notifications, handle responses, obtain push tokens, and configure Android channels |
| One.Clipboard | Read, write, and check clipboard text |
| One.Network, useNetworkState | Read and subscribe to connection state |
| One.Browser | Open a system browser or authentication session and dismiss it |
| One.Auth.Apple | Start Apple authorization on iOS |
| One.Haptics | Produce selection, impact, and notification feedback |
| One.AppInfo | Read the installed version, build, and application identifier |
| One.ImagePicker | Pick library media or use the system camera picker |
| One.DocumentPicker | Select documents with cancellation and file metadata |
| One.SecureStore | Store strings in Keychain or encrypted Android storage, asynchronously or synchronously |
| One.Database | Open and query SQLite databases through OP SQLite |
| One.LaunchScreen | Hold the launch screen until the app is ready, then hide it |
| One.Updates | Check, download, stage, and reload OTA bundles; the launcher handles early-failure rollback and cache pruning |
| One.openURL, One.openShare, One.openSettings | Hand a URL, share payload, or settings request to the system |
| One.Speech | Request permission and start or stop speech recognition |
| global crypto | Generate secure random values and UUIDs |
global fetch |
Stream native HTTP responses and upload React Native file parts |
One.Auth.Apple is grouped under the shared authentication namespace, but Apple
authorization is an iOS operation. On Android and web, isAvailable is false and authorization calls reject.
Secure storage uses string keys and values:
import { One } from 'one'
export async function saveSession(token: string) {
await One.SecureStore.setItem('session', token)
}
export async function readSession() {
return One.SecureStore.getItem('session')
}
export async function clearSession() {
await One.SecureStore.deleteItem('session')
}A missing key reads null. SecureStore rejects on web; choose a separate web
session strategy. The synchronous verbs block the JavaScript thread, so reserve
them for a value needed synchronously, such as an initial session read.
More native services
These service namespaces are exported on One. Their references describe
supported platforms, native configuration and unavailable operations.One.iOS.CameraView and One.iOS.WidgetUI are iOS components.
| API | What you can do |
|---|---|
| One.LocalAuthentication | Check biometric support and request system authentication |
| One.ProtectedStore | Protect Keychain items with biometric or device-passcode access policies |
| One.Storage | Fast synchronous key-value storage |
| One.Location | Request permission, locate, watch position, and geocode; background updates need configuration |
| One.MapServices | Search places, request suggestions, reverse-geocode, and obtain directions |
| One.FileSystem | Read and write sandbox files, inspect directories, and manage files |
| One.Audio | Play and record audio, configure background playback, and handle interruptions and Now Playing |
| One.iOS.CameraView | Render a camera preview and receive scanned codes |
| One.Share | Present the iOS share sheet and receive its result |
| One.PhotoLibrary | Save, query, export, and manage Photos assets and albums |
| One.ImageManipulator | Crop, resize, rotate, and encode local images |
| One.Contacts | Request access, search, create, update, delete, and use the system contact picker |
| One.Calendar | Manage events and reminders, including supported recurrence rules |
| One.Device | Read device and localization snapshots |
| One.Motion | Subscribe to available motion sensors |
| One.ScreenOrientation | Read, lock, unlock, and observe the active scene orientation |
| One.ScreenCapture | Observe capture state and screenshot events; capture a window or mounted view as PNG |
| One.Purchases | Read StoreKit products, purchase, restore, and observe transactions |
| One.BackgroundTasks | Register and schedule system-managed background work |
| One.DeviceAttestation | Use App Attest and DeviceCheck with app-owned server validation |
| One.AppTracking | Read and request tracking permission |
| One.AppIcon | Read and change configured alternate icons |
| One.KeepAwake | Control the app's idle-timer flag |
| One.Print | Present the system print sheet for a local PDF |
| One.StoreReview | Request a system review prompt; iOS decides whether to display it |
| One.QuickActions | Manage Home Screen actions and receive warm or cold selections |
| One.Widgets, One.LiveActivities, One.iOS.WidgetUI | Update a generated WidgetKit extension and ActivityKit presentations |
Widget and Live Activity APIs need a generated extension, App Group, and the
relevant signing capabilities. Native project setup
shows the configuration. Widgets render a supported serialized JSX layout,
not an arbitrary React Native subtree.
Permissions and subscriptions
Configure native usage descriptions and capabilities before building the app,
then request runtime permission from the corresponding API. A camera picker,
camera preview, library picker, and Photos library management have different
permission contracts. The system picker can select media without granting the
app general library access.
A live network subscription returns an object with remove():
import { useEffect, useState } from 'react'
import { One } from 'one'
export function useConnectionType() {
const [type, setType] = useState('unknown')
useEffect(() => {
const subscription = One.Network.addStateListener((state) => {
setType(state.type)
})
return () => subscription.remove()
}, [])
return type
}For a component that also needs the initial snapshot, use the exporteduseNetworkState hook. Other services can return an unsubscribe function or
another handle; follow their own cleanup signature rather than assuming every
service uses remove().
HTTP
Native global fetch streams response bodies and accepts React Native file
parts in FormData. Use its standard request and response objects by calling
the global fetch function directly. Streaming is separate fromOne.Network, which reports connection state.
Apps Onestack.Dev Data Docs Native Components (`apps/onestack.dev/data/docs/native-components.mdx`)
title: Native Components
Use platform components when the screen should follow that platform's controls
and layout. Use One.UI when its shared contract fits the feature. These
components coexist with React Native views; their native layout systems own
the content inside their hosts.
iOS components
The following families are available through One.iOS. Each reference describes
its props, events, child slots, and version limits.
style controls the host's placement in the surrounding React Native layout.swiftStyle applies SwiftUI appearance and modifiers. Native containers can
host React Native content through slots. Use a finite viewport for a scrolling
or filling subtree so that the native container and Yoga have a defined size.
The layout reference explains the boundary options.
Plain controlled values work with React state:
import { useState } from 'react'
import { One } from 'one'
export function Volume() {
const [volume, setVolume] = useState(0.5)
return (
<One.iOS.Slider
label="Volume"
value={volume}
onValueChange={setVolume}
in={[0, 1]}
/>
)
}Keep this component in an iOS branch or an .ios.tsx file. Accept a native
edit by updating the controlled value in its callback. For controls whose
value props accept a native state handle, you can use useNativeState instead;
see TextInput and native state.
One.iOS.withAnimation is SwiftUI's withAnimation. The native state values
its body writes reach their bound views in one SwiftUI transaction, so they
animate together:
const level = useNativeState(0)
<One.iOS.Slider label="Level" value={level} onValueChange={() => {}} />
One.iOS.withAnimation('spring', () => level.set(100), () => console.log('done'))
One.iOS.withAnimation({ kind: 'easeOut', duration: 2 }, () => level.set(0))
One.iOS.withAnimation(
{ kind: 'spring', response: 0.35, dampingFraction: 0.8 },
() => level.set(100)
)The animation accepts SwiftUI's named animations (default, spring,smooth, snappy, bouncy, easeInOut, linear, and the rest), a record
with a factory kind and its parameters, or null to apply the writes without
animation. Durations and spring response times are in seconds. The same
animation values work in swiftStyle.animation and the animation callback ofOne.iOS.PhaseAnimator. The body must write useNativeState handles; ordinary
React state updates do not join the native animation transaction.
The optional completion runs when the
animation is logically complete, or when it is removed if the fourth argument
is 'removed'. A body that throws applies its writes without animation and
skips the completion. A call inside another body throws.
One.iOS.TabView and NavigationStack are explicit native composition APIs.
Use the router's Tabs and
Stack to navigate file routes. Routed stacks also
have a header and toolbar API.
The UIKit-backed toolbar,
split view, and
zoom transitions have separate contracts.
Android components
One.Android exposes Compose and Material components:
| Family | Components |
|---|---|
| Layout | Column, Row, FlowRow, Box, Spacer, LazyColumn, LazyRow |
| Surfaces | Card, ElevatedCard, OutlinedCard, Surface, HorizontalDivider, VerticalDivider |
| Content | Text, Icon, Image, Badge, BadgedBox, ListItem |
| Actions | Button, IconButton, FilledIconButton, FilledTonalIconButton, OutlinedIconButton; FloatingActionButton, SmallFloatingActionButton, LargeFloatingActionButton, ExtendedFloatingActionButton |
| Selection | Switch, Checkbox, RadioButton; ToggleButton, IconToggleButton, FilledIconToggleButton, OutlinedIconToggleButton; SingleChoiceSegmentedButtonRow, MultiChoiceSegmentedButtonRow, SegmentedButton |
| Chips | AssistChip, FilterChip, InputChip, SuggestionChip |
| Input and presentation | TextField, Slider, DatePicker, TimePicker, DatePickerDialog, TimePickerDialog, AlertDialog, Dialog, Snackbar, SnackbarHost, ModalBottomSheet |
| Indicators | ProgressIndicator, LinearProgressIndicator, CircularProgressIndicator, LinearWavyProgressIndicator, CircularWavyProgressIndicator, LoadingIndicator, ContainedLoadingIndicator |
The Compose reference supplies the prop and slot
contracts. Android menus,
icons, and colors have their
own pages.
A Compose root uses React Native style; its descendants use composeStyle.
Supply a finite width, and either a height or a surrounding layout that bounds
the root. Nested rows and columns use Compose's alignment, arrangement, and
weight rules rather than Yoga flex props.
import { useState } from 'react'
import { One } from 'one'
export function AndroidSettings() {
const [enabled, setEnabled] = useState(true)
return (
<One.Android.Column
style={{ width: '100%', height: 180 }}
spacing={12}
composeStyle={{ padding: 16 }}
>
<One.Android.Text text="Notifications" fontSize={20} />
<One.Android.Switch
label="Allow notifications"
isOn={enabled}
onIsOnChange={setEnabled}
/>
</One.Android.Column>
)
}Shared components
| API | Contract |
|---|---|
One.UI.Image |
Image loading, sizing, and load events |
One.UI.Icon |
Per-platform elements and semantic color roles |
One.UI.Map |
MapKit on iOS and Google Maps on Android |
One.UI.Pager |
Swipeable pages, scroll progress, and ref methods |
One.UI.Portal, PortalHost |
Content rendered in a named host elsewhere |
One.UI.TextInput, useNativeState |
Native text input and shared observable state |
One.UI.EdgeFade, Blur, Mask |
Native effects and web behavior |
One.UI.PictureInPicture |
Native picture-in-picture content and inline web content |
One.UI.SafeArea |
Provider, View, and initial metrics |
One.UI.Fonts, useFonts |
Bundled and loaded fonts |
One.UI.ReservedRegions |
View-scoped reserved areas, size class, hinges, and window segments |
The One router installs its root safe-area provider. Use an additional provider
only when a subtree needs its own coordinate space. Shared does not imply
identical web behavior: Map renders no web view, Blur and Mask require native,
and PictureInPicture renders inline on web. Consult the
platform notes before selecting a shared component.
Apps Onestack.Dev Data Docs Native Platforms (`apps/onestack.dev/data/docs/native-platforms.mdx`)
title: Native Platform Notes
The v2 native surface targets React Native 0.87 and the New Architecture.
One's iOS package floor is 17. That deployment target is separate from the OS
version required by an individual API.
iOS availability
Modern SwiftUI tabs need iOS 18; an older TabView path serves earlier supported
iOS versions. Presentation sizing also needs iOS 18. Liquid Glass, glass button
styles, WebView, and tab-bar minimization require iOS 26. Some generated
modifiers require newer SDKs and runtime versions.
Unavailable behavior depends on the component. Unsupported enum values are
validated before reaching native code. WebView renders empty below iOS 26;
Glass uses its configured material fallback on older systems. Read the
component reference before relying on a newer
feature. A lower deployment target does not make a newer API available.
Hardware and system policy matter too. Cameras, motion sensors, successful
device attestation, store purchases, printer delivery, and background scheduling
have limits that a simulator cannot fully prove. Review prompts are requests to
iOS, not a promise that a dialog will appear. BackgroundTasks follows the OS's
scheduling policy rather than a JavaScript timer.
Android availability
Compose components require an Android native binary and the New Architecture.
Their controls and default colors come from Material. Native menus use
Android popup surfaces; they are distinct from the iOS menu contract.
Google Maps needs the app's API key and build configuration. Push notifications
need their platform configuration as well. See the
Map and Notifications references.
Use the generated project's Android SDK settings and JDK 17. Raising or
lowering native.app.android SDK levels must remain compatible with React
Native and the native dependencies your app installs.
Web behavior
Platform-only SwiftUI and Compose components throw when rendered on the wrong
platform. Select a platform branch or use platform-specific files. Shared
components and services have individual web behavior:
| Surface | Web behavior |
|---|---|
| Image, TextInput, Icon | Render web content; Icon needs your web element |
| EdgeFade | CSS rendering |
| Blur, Mask | Require native and throw |
| Map | Renders no map |
| PictureInPicture | Renders inline content |
| SafeArea | Uses CSS safe-area environment values |
| Clipboard, Network, Browser | Use the corresponding browser facilities |
| Haptics, Notifications | Inert feedback or permission results; see each API |
| SecureStore | Rejects; synchronous verbs throw |
| iOS services | Generally reject unsupported calls; check the service reference |
The UIKit toolbar, menu-action, and zoom integrations return null on
unsupported platforms. SplitView renders its routed slot without native
columns on Android and web. These exceptions do not make arbitraryOne.iOS components safe to render everywhere.
See the per-feature platform table for more detail.
Native rebuilds and updates
Adding a native dependency, changing permissions or entitlements, generating a
widget extension, or editing Swift or Kotlin requires a native rebuild. Hot
reload updates JavaScript; it does not replace compiled native code.
OTA updates must match the runtime and native capabilities of the installed
binary. A JavaScript bundle cannot add a missing native module. Configure the
Updates service or another supported
OTA integration around that boundary.
Layout and accessibility
Use React Native style for a native host's outer bounds, and swiftStyle orcomposeStyle for the corresponding native content. Avoid having a
content-sized parent and a filling child depend on each other's size.
Scrolling and presentation content needs a defined viewport.
Imported Kotlin composables contribute their natural content size to the React
Native layout. The surrounding Yoga container retains parent stretch, flex,
and minimum or maximum dimensions. Content height updates when composable
props or Kotlin state changes.
Keep labels, accessibility props, and native control state aligned with what
the user sees. Verify the actual platform screen, including Dynamic Type,
keyboard, safe areas, dark appearance, and the OS versions you support. A web
preview establishes the web implementation; it does not establish native
behavior.
Apps Onestack.Dev Data Docs Native Setup (`apps/onestack.dev/data/docs/native-setup.mdx`)
title: Native Setup
One Native is included in the one package. There is no separate native UI
package to install. One v2 uses React 19.3.0 and React Native 0.88;
the current examples use 0.88.0. Native components require the New Architecture. The
installation guide lists the full dependency set,
including screens, Worklets, and Liquid Glass.
The iOS deployment floor is 17. See the
platform notes for feature-specific requirements.
For a new app, start with:
npx oneFor an existing One app, update one to v2 and align its React, React
Native, and React Navigation peers. Use your package manager to inspect the
installed package's peer dependencies. The navigation peers are 8 alpha
versions, so keep their versions aligned.
Configure the app
Configure the application through native.app in vite.config.ts:
import { one } from 'one/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
one({
native: {
app: {
name: 'MyApp',
ios: {
bundleId: 'com.example.myapp',
deploymentTarget: '17.0',
},
android: {
applicationId: 'com.example.myapp',
},
},
},
}),
],
})Add react-native.config.cjs at the app root:
module.exports = require('one/react-native-config')For an existing app, install the community native template as a development
dependency. One supplies the CLI and platform build tools:
npm install --save-dev @react-native-community/[email protected]Then generate the projects from the app directory:
one prebuildOne generates ios/ and android/ from the React Native community templates.
React Native autolinking includes One's pod, Android module, Fabric components,
and native service dependencies. Run this through the app's scripts or your
package manager's local executable command if one is not on your shell PATH.
Use native.app for icons, launch screens, fonts, deep links, and permissions.
The native project guide lists the
manifest fields. Give each permission a description of why your app needs it.
For example, native.app.imagePicker.camera supplies the camera usage
configuration. Updating native configuration or adding a native dependency
requires generating and rebuilding the native project.
One's native startup installs the ES2023 array methods toSorted, toReversed,toSpliced, and with before app modules load. This includes Hermes releases
whose built-in array methods are incomplete. Apps use the standard methods
directly; dependency code and app startup need no separate polyfill setup.
Apps that keep Expo prebuild follow Coming from Expo.
Run the app
Start One's development server in one terminal:
one devBuild and launch the generated app in another:
one run:iosOr use one run:android. iOS builds need macOS, Xcode, and CocoaPods. Android
builds need the Android SDK and JDK 17. Run native compilation on your build
machine or CI as appropriate for your project.
One bundles native apps with Rolldown, the same bundler as web.
ONE_PUBLIC_ variables are exposed to app code; see
Environment. Rebuild the native app for native code or
manifest changes, even when JavaScript hot reload is working.
Add a first native control
Put this in an iOS route, such as app/settings.ios.tsx:
import { useState } from 'react'
import { One } from 'one'
export default function Settings() {
const [enabled, setEnabled] = useState(true)
return (
<One.iOS.Form style={{ flex: 1 }}>
<One.iOS.Section title="Notifications">
<One.iOS.Toggle
label="Allow notifications"
isOn={enabled}
onIsOnChange={setEnabled}
/>
</One.iOS.Section>
</One.iOS.Form>
)
}The callback updates the controlled setting in React state. For shared native
text-input state, use useNativeState with TextInput.
This sample changes a setting only; requesting notification permission is a separate
Notifications API call.
Apps Onestack.Dev Data Docs Native Source Events (`apps/onestack.dev/data/docs/native-source-events.mdx`)
title: Native events
A method whose only parameter is a callback is a subscription, not a call.
Calling it from JavaScript returns an unsubscribe function. Native posts
each payload over the one event bridge and the listener receives the
decoded values.
@MainActor final class Clock: PeachModule {
init() {}
private var tick: ((Double) -> Void)?
func onTick(_ callback: @escaping (Double) -> Void) {
tick = callback
}
}object Clock : OneModule {
private var tick: ((Double) -> Unit)? = null
fun onTick(callback: (Double) -> Unit) {
tick = callback
}
}import { Clock } from './native/Clock.swift'
const unsubscribe = Clock.onTick((at) => {
console.log('tick', at)
})
unsubscribe()The callback may take zero or more parameters; each one decodes with the
same wire rules as method arguments, so structs, enums, Date and Data
payloads revive before the listener runs. Pass a real function: anything
else throws before the subscription leaves JavaScript. Calling the returned
function removes the JavaScript listener, so call it when the component
unmounts.
Native keeps its own stored closure, so the author owns the native side of
the lifecycle: emitting after every JavaScript listener unsubscribed posts
to nobody, and a closure the author never releases stays retained. Under
the Peach simulator there is no JavaScript runtime to receive an event, so
emitted events queue in the module instead: the simulator host drains them
with the peach_poll_events export, which returns a length-prefixed JSON
array of { channel, args } pairs using the same framing aspeach_call_poll.
Apps Onestack.Dev Data Docs Native Source Expo (`apps/onestack.dev/data/docs/native-source-expo.mdx`)
title: Swift and Kotlin in Expo prebuild apps
Apps on Expo prebuild import Swift and Kotlin with the nativeSource option
of vxrn/expo-plugin. See
Coming from Expo.
Apps Onestack.Dev Data Docs Native Source Payloads (`apps/onestack.dev/data/docs/native-source-payloads.mdx`)
title: Native payload types
Method arguments, results, view props and callback arguments cross as JSON
by default, and four richer shapes cross with it: a Codable struct ordata class crosses as its object shape, a String or Int backed enum
crosses as its raw value, a Date crosses as an ISO string revived to aDate, and Data or ByteArray crosses as base64 revived to aUint8Array.
struct Point: Codable {
var x: Double
var y: Double
var label: String? = nil
}
enum Direction: String {
case north
case south
}
@MainActor final class Atlas: PeachModule {
init() {}
func pin(_ point: Point) -> Point { point }
func heading(_ direction: Direction) -> Direction { direction }
func at(_ when: Date) -> Date { when }
}data class Point(val x: Double, val y: Double, val label: String? = null)
enum class Direction {
NORTH,
SOUTH
}
object Atlas : OneModule {
fun pin(point: Point): Point = point
fun at(when: Date): Date = when
fun blob(data: ByteArray): ByteArray = data
}import { Atlas, type Point } from './native/Atlas.swift'
const point: Point = { x: 1, y: 2 }
await Atlas.pin(point)
await Atlas.at(new Date())A struct field with a value is optional in TypeScript; Codable and the
generated Kotlin glue fill the default when the key is missing. A Kotlin
default must be visible to the generated glue. Structs nest, including
inside arrays and maps. An enum needs its cases listed without explicit raw
values or associated values. Every struct and enum lives in the file that
uses it, must be visible to the generated glue, and a Swift struct must
conform to Codable in its declaration. Anything else keeps failing with
the file and line that declares it.
Pass a real Date and a real Uint8Array: anything else throws before the
call leaves JavaScript. Native results revive the same way, so what arrives
is a Date and a Uint8Array, never the string on the wire.
Apps Onestack.Dev Data Docs Native Source (`apps/onestack.dev/data/docs/native-source.mdx`)
title: Swift and Kotlin
An app can import its own Swift and Kotlin files. One reads each file's
declarations, writes a TypeScript declaration next to it, and generates the
native glue that one prebuild compiles into the app. There is no module
boilerplate, no bridge registration and no codegen step to run by hand.
import { Device } from './native/Showcase/Device.swift'
const hash = await Device.sha256('hello')Swift imports run on iOS and Kotlin imports run on Android. Native source
requires a project generated by one prebuild; apps on Expo prebuild follow
Coming from Expo.
Swift functions
Put Swift files in a Swift package: any directory under the app with aPackage.swift. The directory name names the package, so keep it unique and
different from the app's own name. A nested JavaScript package (a directory
with its own package.json) counts only when its root holds thePackage.swift; Swift packages deeper inside it belong to that package.
// native/Showcase/Package.swift
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "Showcase",
platforms: [.iOS(.v17)],
targets: [
.executableTarget(name: "Showcase", path: ".", swiftSettings: [.swiftLanguageMode(.v6)]),
]
)A class that conforms to PeachModule becomes a named export. Each method that
is not private or fileprivate becomes an async function:
// native/Showcase/Device.swift
import CryptoKit
import UIKit
@MainActor final class Device: PeachModule {
init() {}
func info() -> [String: String] {
["model": UIDevice.current.model, "system": UIDevice.current.systemVersion]
}
func sha256(_ text: String) -> String {
SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined()
}
}import { Device } from './native/Showcase/Device.swift'
await Device.info() // Promise<Record<string, string>>
await Device.sha256('hello') // Promise<string>Methods may be async and may throw; a thrown error rejects the promise.
The package's sources may import Apple frameworks. SwiftPM package
dependencies are not supported; add native libraries as React Native
dependencies instead.
Swift views
A public struct that conforms to View becomes a named React component. Its
stored properties are the props, and a closure property is a callback:
// native/Showcase/Level.swift
import SwiftUI
public struct Level: View {
let value: Double
let onChange: (Double) -> Void
public var body: some View {
Slider(value: Binding(get: { value }, set: onChange))
}
}import { Level } from './native/Showcase/Level.swift'
<Level value={value} onChange={setValue} />The view is built through its memberwise initializer, so it has no init of
its own. A property that already has a value, such as @State private var dragging = false, stays the view's own. A property typed T? is an optional
prop, and a var may only default to nil. @Binding is not a prop; take a
value and a callback instead. Views that are not public stay inside the
package.
A package as one view
The package's @main type is the default export of its file when it conforms
to PeachPackage. The element's props arrive as untyped JSON:
@main struct Badge: PeachPackage {
func view(props: JSON) -> some View {
Text(props["title"]?.stringValue ?? "")
}
}import Badge from './native/Badge/Badge.swift'
<Badge title="new" />A package has at most one @main type. A whole SwiftUI app
(@main struct MyApp: App) imported this way renders its scene's root view and
fills the space it is given.
Kotlin functions and views
A Kotlin file can sit anywhere in the app outside android, ios,node_modules and nested packages. Packages declared in the build root'sworkspaces are included; their source IDs use paths relative to that build root. An object that implements OneModule
becomes a named export, and each of its public functions an async function.suspend functions are awaited. A public @Composable function with a
capitalized name becomes a React component whose props are its parameters;
function-typed parameters are callbacks. Views compile against Compose UI,
Foundation, Material 3 and Navigation.
// native/Device.kt
import android.os.Build
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.onejs.one.source.OneModule
import java.security.MessageDigest
object Device : OneModule {
fun info(): Map<String, String> =
mapOf("model" to Build.MODEL, "system" to Build.VERSION.RELEASE)
fun sha256(text: String): String =
MessageDigest.getInstance("SHA-256")
.digest(text.toByteArray())
.joinToString("") { "%02x".format(it) }
}
@Composable
fun Level(value: Double, onChange: (Double) -> Unit, modifier: Modifier = Modifier) {
Slider(value = value.toFloat(), onValueChange = { onChange(it.toDouble()) }, modifier = modifier)
}import { Device, Level } from './native/Device.kt'
<Level value={value} onChange={setValue} />A composable's Modifier parameter must default to Modifier; One supplies
it. Other parameters may only default to null, which makes the prop optional.
The composable sizes to its content inside the surrounding layout.
Types
These types cross the boundary as JSON:
| Swift | Kotlin | TypeScript |
|---|---|---|
String |
String |
string |
Bool |
Boolean |
boolean |
Double, Float, Int |
Double, Float, Int, Long |
number |
[T] |
List<T> |
T[] |
[String: T] |
Map<String, T> |
Record<string, T> |
T? |
T? |
T | null |
Void |
Unit |
void |
These types also cross as view props and callback arguments. Integer arguments
must be whole numbers in range, and Long results must fit in JavaScript's
safe integer range. Overloads, generics, default arguments and static methods
are rejected with the file and line.
The declaration sits beside the source as Device.d.swift.ts orDevice.d.kt.ts. The dev server and one prebuild rewrite it whenever they
read the file, and one typegen writes all of them. TypeScript reads these
declarations when allowArbitraryExtensions is enabled in tsconfig.json.
One import on every platform
A .swift import only bundles for iOS and a .kt import only for Android. To
use one API everywhere, give the platforms their own entry files and keep the
web implementation in TypeScript:
features/native/device.ios.tsx export { Device } from '../../native/Showcase/Device.swift'
export { Level } from '../../native/Showcase/Level.swift'
features/native/device.android.ts export { Device, Level } from '../../native/Device.kt'
features/native/device.tsx export const Device = { ... } // webScreens import features/native/device and each platform's bundler picks its
file. The starter app created by npx one includes this example at the/native route.
Rebuilds
Native source compiles into the app binary. After adding or editing a Swift or
Kotlin file, run one prebuild and rebuild the app. Each generated function
carries a hash of its native contract: if JavaScript reloads against a binary
built from a different contract, calls reject with E_NATIVE_SOURCE_REBUILD
instead of reaching mismatched code. OTA updates follow the same rule; see
native rebuilds and updates.
Apps Onestack.Dev Data Docs One Build (`apps/onestack.dev/data/docs/one-build.mdx`)
title: one build
Building your app for production happens through one build.
It takes the following arguments (see the CLI reference for every command):
npx one build [options]
--platform web|ios|android # what to build (default: web)
--only <pattern> # limit the pages built to a micromatch pattern
--step <name> # run a single internal build step
--skip-env # skip loading .env files during buildTo build for iOS for example, you would run:
npx one build --platform iosBuilding for web
When building One for web, the output will be a mostly static directory of HTML, CSS, and JavaScript. Internally, One runs a few different Vite production builds to output client, server, and API folders. This output is always in the dist directory.
Ensure your .env file has a ONE_SERVER_URL set to your production URL, like:
ONE_SERVER_URL=https://onestack.devWe provide a production server based on Hono with one serve that handles loaders, SSR, and API routes for you. You can choose a deploy target for that server. This can be chosen via your Vite configuration:
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
deploy: 'vercel' // defaults to 'node'
}
})
]
}For detailed deployment instructions, see the Deployment Guide.
If you are only using SPA and SSG routes and aren't using loaders, you can statically serve the results of dist/client using any server you'd like.
Parallel builds with worker threads
By default, One uses worker threads to build static pages in parallel across multiple CPU cores. This can significantly speed up builds for sites with many pages.
One uses two workers by default, which keeps peak memory low. Set build.concurrency to use more when the machine has memory to spare:
import { one } from 'one/vite'
export default {
plugins: [
one({
build: {
concurrency: 4
}
})
]
}ONE_BUILD_CONCURRENCY overrides the config value for one-off builds:
ONE_BUILD_CONCURRENCY=2 npx one buildYou can also disable worker threads entirely through config or an environment variable:
one({
build: {
workers: false
}
})ONE_BUILD_WORKERS=0 npx one buildSecurity scanning
One automatically scans your client bundles for leaked secrets after building. By default it logs warnings — for production deployments, we recommend setting securityScan to 'error' to fail the build if secrets are found:
import { one } from 'one/vite'
export default {
plugins: [
one({
build: {
securityScan: 'error'
}
})
]
}The scanner detects common API key patterns (Anthropic, OpenAI, Stripe, GitHub, AWS), bearer tokens, and generic secret assignments. See Configuration: build.securityScan for all options.
Building for native
Build the native JS bundle with --platform ios or --platform android. This always runs through the Rolldown path, regardless of your native.bundler dev setting.
To ship to devices, integrate the bundle into the existing native build process. See Build or Run iOS for archiving and uploading.
Or check out the iOS Native Guide for instructions on building your iOS app with Xcode.
Apps Onestack.Dev Data Docs One Dev (`apps/onestack.dev/data/docs/one-dev.mdx`)
title: one dev
Developing an app with One is as simple as one dev.
It takes the following arguments (see the CLI reference for every command):
--clean # boolean, will clear all caches before running
--host # string, set the hostname to bind to
--port # string, set the port to bind to
--mode # "development" or "production": serve the production bundle from the dev server
--debug-bundle # string, write the bundle to a temp file and serve it from there for debugging
--debug # string, pass debug args to Vite
--extra-config # string, path to an extra Vite config file to merge on top of the project configYou can also set the port with the ONE_PORT or ONE_FORCE_PORT environment variables. See Environment Variables for details.
The One development server serves both web and native apps on the same port at the same time, and should hot reload both at once. When you see the Server running on message with a host and port, that is the same for both native and web.
Native Development
The one dev command only starts the development server — it does not build or run the native iOS/Android shell. One ships native code inside the one package, so native development uses your own app build:
- Run
one prebuildto generate the native Xcode/Android project - Run
one run:iosorone run:androidto build and launch the app
See the Build or Run the Native iOS App guide for detailed instructions.
Apps Onestack.Dev Data Docs One Serve (`apps/onestack.dev/data/docs/one-serve.mdx`)
title: one serve
Serve your web apps for production with one serve, after you've successfully run one build.
It takes the following arguments (see the CLI reference for every command):
--host # string, set the hostname to bind to
--port # string, set the port to bind to
--compress # boolean, enable gzip compression, defaults to true
--loadEnv # boolean, whether to load .env files before running
--outDir # string, build output directory (default: dist)
--cluster # enable cluster mode using all CPU cores
--cluster=N # enable cluster mode with N workersYou can also set the port with the ONE_PORT or ONE_FORCE_PORT environment variables. See Environment Variables for details.
One comes with a serve command that is powered by Hono.
If you are only using SPA and SSG routes and aren't using loaders, you don't need to use one serve if you don't want to - you can statically serve the results of dist/client using any server you'd like.
You must run a one build before serve.
Cluster Mode
For production deployments under heavy load, one serve supports cluster mode. This forks multiple Node.js worker processes that share the same port, distributing requests across CPU cores.
one serve --cluster # use all CPU cores
one serve --cluster=4 # use 4 workers
one serve # single process (default)Each worker handles requests independently. If a worker crashes, it automatically restarts with backoff protection to prevent crash loops.
When to use cluster mode
- High-traffic production deployments
- Servers with many CPU cores
- Handling 200+ concurrent connections
- SSR-heavy workloads where renders block the event loop
When single process is better
- Development (use
one devinstead) - Low-traffic sites where single process is simpler and has less overhead
- Less than ~100 concurrent connections, where single process may be faster due to less IPC overhead
Performance
At 500 concurrent connections (production-style load test):
- Single process: ~2,150 req/s
- 8 workers: ~2,939 req/s (1.4x improvement)
- At 200 connections, cluster is 2.4x faster than single process
Programmatic Usage
You can also run your production server programmatically:
import { serve } from 'one/serve'
await serve()Which takes options:
type ServeOptions = {
// you can pass in your own Hono server
app?: Hono
host?: string
port?: number
compress?: boolean
outDir?: string
/**
* Whether to run the Vite logic to load .env files before running the server
* @default false
*/
loadEnv?: boolean
/**
* Enable cluster mode with multiple worker processes.
* Pass true to use all CPU cores, or a number for specific worker count.
*/
cluster?: boolean | number
}Apps Onestack.Dev Data Docs Render Modes (`apps/onestack.dev/data/docs/render-modes.mdx`)
title: Render Modes
A unique feature of One is the ability to seamlessly route across a variety of route modes. What this means in plain english, is that you can choose whether each individual page within your routes uses one of a few strategies, such as rendering statically at build time, or rendering on-demand at request time.
One supports the following render modes, which are explained in detail below: api, spa, ssr, and ssg.
You can set your global render mode with the One vite plugin. The default is ssg.
import { one } from 'one/vite'
export default {
plugins: [
one({
web: {
defaultRenderMode: 'spa'
}
})
]
}To specify the render mode on a per-page basis, you add a suffix to the filename, like so:
route+ssg.tsx- Matches/route, but will render the page as a SSG route.route+spa.tsx- Matches/route, but will render the page as a SPA route.route+ssr.tsx- Matches/route, but will render the page as a SSR route.route+api.tsx- Matches/route, but will render the page as an API route.
Layout Render Modes
Layouts can also specify render modes using the same suffix pattern:
_layout+ssg.tsx- Layout is pre-rendered at build time (static)_layout+ssr.tsx- Layout is rendered on the server per-request_layout+spa.tsx- Layout is client-rendered only (default behavior)
Why this matters for SPA pages:
When an SPA page has a parent layout with +ssg or +ssr render mode, the layout shell is rendered on the server while the page content remains client-rendered. This gives users immediate visual content (navigation, sidebar, head tags, CSS) without waiting for JavaScript to download and execute.
app/
├── _layout+ssg.tsx # Root layout rendered at build time
├── dashboard+spa.tsx # SPA page - but layout shell is pre-rendered!
└── settings+spa.tsx # Same - instant layout, client page contentThis is the recommended approach for SPA-heavy apps that still want fast initial paint. The layout's HTML is included in the initial response, and page content hydrates after JavaScript loads.
Folder-Level Render Modes
You can also apply render modes to entire folders, and all routes within that folder will inherit the render mode. This is useful for organizing routes by their rendering strategy:
app/
├── dashboard+ssr/ # All routes use SSR
│ ├── index.tsx # Uses SSR
│ ├── analytics.tsx # Uses SSR
│ └── settings+spa.tsx # Overrides to SPA
├── blog+ssg/ # All routes use SSG
│ ├── index.tsx # Uses SSG
│ └── [slug].tsx # Uses SSG
└── index.tsx # Uses vite config defaultRender mode hierarchy:
- File suffix (highest priority) -
page+spa.tsx - Parent folder suffix -
dashboard+ssr/page.tsx - Vite configuration (lowest priority) -
defaultRenderMode: 'ssg'
Nested folders:
Inner folder suffixes override outer folder suffixes:
app/
└── dashboard+ssr/
├── index.tsx # Uses SSR
└── reports+ssg/
└── monthly.tsx # Uses SSG (inner folder wins)Important notes:
- Folder suffixes are stripped from URLs:
dashboard+ssr/analyticsbecomes/dashboard/analytics - File suffixes always take precedence over folder suffixes
- Layouts do not inherit folder render modes (they remain as layouts)
API routes benefit too:
With folder suffixes, API routes no longer require the +api.tsx file suffix:
app/
└── api+api/ # Folder suffix
├── users.tsx # No +api.tsx needed! → /api/users (API)
├── posts.tsx # No +api.tsx needed! → /api/posts (API)
└── auth/
└── login.tsx # Inherits API type → /api/auth/login (API)This makes organizing API routes much cleaner, especially for versioned APIs like v1+api/ and v2+api/.
Modes
Note that render modes mostly only apply to the web. On native, everything is essentially a SPA, with all the JS for every route included in the app the user downloads from the app store. Because native apps don't have to deal with the network, this SPA mode is ideal and has very little downsides.
SSG (the default)
When you don't add a + suffix to your route, it will default to an "SSG" route, unless you set the web.defaultRenderMode configuration otherwise. An example of a SSG route would be something like app/blog.tsx if you haven't changed the defaultRenderMode, or if you have app/blog+ssg.tsx.
SSG Stands for "Server Side Generated", it's a strategy for web where you pre-render the entire HTML and CSS of a page at build-time fully on the server (or in CI) so that you can serve the page quickly from something like a CDN, without running any JavaScript at the time of request.
We chose SSG as the default behavior as it serves as a good balance between simplicity and performance. What it is less good at is serving dynamic content to an individual user.
That said - you can still do dynamic content on top of an SSG page. It's just that you'll have to have the dynamic content load in after the browser finishes downloading and parsing the JS for that page, and it will need to gracefully replace the static content that the user sees.
This pattern is great for things like a SaaS homepage that shows mostly static content, but where you may want to load a logged-in menu for the current user. It's not good for something like a logged-in dashboard.
SPA
If you name a route with the +spa suffix, like app/dashboard+spa.tsx, it will no longer render on the server - either at build-time or at request. Instead, at build time One will build just the client-side JavaScript necessary.
This render mode is great for highly dynamic use cases. Think a Linear, or a Figma, a metrics dashboard, or a user account panel. It is simpler to build, and doesn't require making sure every dependency works on both Node.js and in the browser.
Its downsides include a slower initial load time, and worse SEO. You can mitigate this by using a _layout+ssg.tsx or _layout+ssr.tsx root layout, which renders your layout shell on the server while keeping page content client-rendered. See Layout Render Modes above.
SSR
If you name a route with the +ssr suffix, like app/issues+ssr.tsx, you will get a server rendered page. This mode will generate the JS for both server and client at build-time, but instead of rendering out the HTML and CSS right then, it will instead wait for a request to the production server before it imports the server JS, renders the page, and then returns the HTML and CSS.
This mode is great for things that need to be dynamically rendered more often than whenever you deploy. One example is something more like GitHub Issues. By server rendering a page like this, you get faster initial loads, and better SEO than a SPA-type page. But it is the most expensive in terms of cost - each request will now run a full render of the route, and also slower than SSG in terms of initial response time, at least by default.
In the case of a GitHub Issues type page, what you'd do is cache the SSR response on a CDN, and then clear the CDN cache whenever data for that page updates. This is complex, and generally SSR is the most complex of the three render modes to support. Because it is more complex and more expensive, we generally recommend using SPA or SSG unless you are certain you can afford to pay for the extra render cost, and/or cache responses without too much trouble.
API
Adding +api.ts to the end of your filename turns a route into an API route. API routes are HTTP endpoints that you call from other routes or services, not pages that users navigate to directly.
export function GET(request: Request) {
return Response.json({ message: 'Hello!' })
}API routes use Web standard Request and Response objects.
See API Routes for full documentation.
Apps Onestack.Dev Data Docs Routing Api Routes (`apps/onestack.dev/data/docs/routing-api-routes.mdx`)
title: API Routes
API routes let you create HTTP endpoints in your One app. Add +api.ts to the end of your filename and export handler functions for each HTTP method.
Basic Usage
export function GET(request: Request) {
return Response.json({ message: 'Hello!' })
}This creates an endpoint at /api/hello that responds to GET requests.
HTTP Methods
Export named functions for each HTTP method:
export function GET(request: Request) {
return Response.json({ users: [] })
}
export function POST(request: Request) {
// handle POST
}
export function PUT(request: Request) {
// handle PUT
}
export function DELETE(request: Request) {
// handle DELETE
}
export function PATCH(request: Request) {
// handle PATCH
}
export function HEAD(request: Request) {
// set headers for a GET without returning a body
return new Response(null, { headers: { 'x-total-count': '42' } })
}
export function OPTIONS(request: Request) {
// shape CORS preflight responses — see the parity note below
return new Response(null, {
status: 204,
headers: {
'access-control-allow-origin': request.headers.get('origin') || '*',
'access-control-allow-methods': 'GET, POST, OPTIONS',
},
})
}You can also export a default function as a catch-all:
export default function handler(request: Request) {
return Response.json({ method: request.method })
}If a route has no handler for the requested method and no default export, One returns 405 Method Not Allowed with an Allow header listing its exported HTTP method handlers. API routes require explicit HEAD and OPTIONS handlers, or a default handler; middleware can also answer these requests.
Dev and prod parity
API route handlers — including HEAD and OPTIONS — are dispatched the same way in both one dev (Vite) and one serve (Hono on Node / Cloudflare Workers / Vercel). Your middleware chain and method exports are the source of truth: whatever response they produce in dev is what ships in prod.
For OPTIONS preflight specifically, One configures Vite's dev server with server.cors: { preflightContinue: true, origin: true, credentials: true } so the built-in cors middleware sets permissive headers on non-preflight requests but lets preflight OPTIONS fall through to your handlers. If you override server.cors in your own vite.config.ts, keep preflightContinue: true or your middleware's preflight response won't reach the browser in dev.
Dynamic Routes
Use brackets for dynamic segments. Params are passed as the second argument:
export function GET(request: Request, { params }: { params: { id: string } }) {
return Response.json({ id: params.id })
}Rest parameters work the same way:
export function GET(request: Request, { params }: { params: { path: string[] } }) {
const filePath = params.path.join('/')
return Response.json({ path: filePath })
}Request Handling
Reading the Body
export async function POST(request: Request) {
// JSON
const json = await request.json()
// Form data
const form = await request.formData()
// Raw text
const text = await request.text()
return Response.json({ received: true })
}Query Parameters
export function GET(request: Request) {
const url = new URL(request.url)
const page = url.searchParams.get('page') || '1'
const limit = url.searchParams.get('limit') || '10'
return Response.json({ page, limit })
}Headers
export function GET(request: Request) {
const auth = request.headers.get('Authorization')
return Response.json({ authenticated: !!auth })
}Response Handling
Custom Status Codes
export async function POST(request: Request) {
const data = await request.json()
if (!data.name) {
return Response.json({ error: 'Name required' }, { status: 400 })
}
return Response.json({ id: 1, ...data }, { status: 201 })
}Custom Headers
export function GET(request: Request) {
return new Response(JSON.stringify({ data: [] }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
})
}Redirects
import { redirect } from 'one'
export function GET(request: Request, { params }: { params: { id: string } }) {
return redirect(`/api/v2/users/${params.id}`)
}Error Handling
export async function GET(request: Request, { params }: { params: { id: string } }) {
const user = await getUser(params.id)
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 })
}
return Response.json(user)
}Web Standards
API routes use the standard Web Request and Response APIs. TypeScript and Node >= 20 include these globally.
Type Helpers
One exports an Endpoint type for simple cases:
import type { Endpoint } from 'one'
export const GET: Endpoint = (request) => {
return Response.json({ hello: 'world' })
}For typed params, use createAPIRoute:
import { createAPIRoute } from 'one'
export const GET = createAPIRoute<'/api/users/[id]'>((request, { params }) => {
// params.id is typed as string
return Response.json({ id: params.id })
})Cloudflare Worker Runtime
When deployed to Cloudflare Workers, API handlers also receive a worker context containing the worker's env bindings and executionCtx (with waitUntil and passThroughOnException). The worker key is undefined on Node/dev so you can write runtime-portable code.
import { createAPIRoute } from 'one'
export const POST = createAPIRoute(async (request, { worker }) => {
const body = await request.json()
// fire-and-forget: keep the worker alive for the background task
// without blocking the response
worker?.executionCtx?.waitUntil(
fetch('https://analytics.example.com/track', {
method: 'POST',
body: JSON.stringify(body),
})
)
return Response.json({ ok: true })
})The worker context has this shape:
type WorkerContext = {
env?: Record<string, unknown> // your wrangler bindings (KV, D1, R2, etc.)
executionCtx?: {
waitUntil: (p: Promise<unknown>) => void
passThroughOnException: () => void
}
}Use env to access bindings you've configured in wrangler.json (databases, KV namespaces, secrets, the ASSETS static-asset binding), and executionCtx.waitUntil() for background work that should outlive the response.
Apps Onestack.Dev Data Docs Routing Exports (`apps/onestack.dev/data/docs/routing-exports.mdx`)
title: Routing Exports
This page catalogs all the special exports One supports from route files.
SuspenseFallback
A _layout route can export a SuspenseFallback component to show loading UI while one of its child routes is suspended. Nested layouts inherit the fallback, and the nearest layout export takes precedence.
import { ActivityIndicator, Text, View } from 'react-native'
import { Stack, type SuspenseFallbackProps } from 'one'
export function SuspenseFallback({ params }: SuspenseFallbackProps) {
return (
<View>
<Text>Loading profile {params.id}...</Text>
<ActivityIndicator />
</View>
)
}
export default function AppLayout() {
return <Stack />
}The component receives the suspended route module's contextKey as route and its URL parameters as params. Route fallbacks are enabled by default on native. On web, set web.suspendRoutes to true in the One config to enable route Suspense boundaries.
loader
Export a loader function to load data for the route on the server before it renders. See Loaders for the full guide, including loader arguments, redirects, caching, and validation.
ErrorBoundary
Export an ErrorBoundary component to render a fallback when the route throws during render or its loader fails:
import { type ErrorBoundaryProps } from 'one'
export function ErrorBoundary({ error, retry, route }: ErrorBoundaryProps) {
return (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={() => retry()}>Try again</button>
</div>
)
}
export default function Dashboard() {
return <div />
}The component receives error, a retry() function that clears the error state and re-renders, and optional route info (pathname, routeName, params, errorType, componentStack; see ErrorRouteInfo).
validateParams
Export a validateParams schema to validate route params on the client before navigation. Works with Zod, Valibot, or custom functions. See Route Validation.
validateRoute
Export an async validateRoute function for checks like resource existence before navigation. See Route Validation.
generateStaticParams
This export is needed for "ssg" type pages that use dynamic path segments (for example, [id].tsx). At build-time, One will expand the id segment into multiple pages, using the values you return from generateStaticParams.
The entire function and export generateStaticParams will be removed at build-time and everything it depends on will be tree-shaken out of your client-side bundle.
You'll need to return an array of objects, where the object keys match the name inside the brackets. As an example, we'll use nested dynamic segments:
export async function generateStaticParams() {
const blogPosts = await getAllBlogPosts()
return blogPosts.map((post) => {
return {
month: post.month,
year: post.year,
slug: post.slug,
}
})
}If this function returned this:
;[
{
month: 10,
year: 2025,
slug: 'some-slug',
},
]Then you'd generate one page at /blog/10/2025/some-slug.
sitemap
Control how a route appears in the generated sitemap.xml. This export is only used when web.sitemap is enabled in your config.
// Customize sitemap entry for this route
export const sitemap = {
// Priority (0.0 to 1.0) - higher means more important
priority: 0.8,
// How often the content changes
changefreq: 'daily',
// Last modification date (optional)
lastmod: '2024-12-15',
}You can also exclude a route from the sitemap entirely:
export const sitemap = {
exclude: true,
}Available options:
| Option | Type | Description |
|---|---|---|
priority |
number |
Value from 0.0 to 1.0 indicating relative importance |
changefreq |
string |
One of: 'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never' |
lastmod |
string | Date |
Last modification date for this route |
exclude |
boolean |
Set to true to exclude this route from the sitemap |
Route-level exports override the defaults set in your config.
Apps Onestack.Dev Data Docs Routing Layouts (`apps/onestack.dev/data/docs/routing-layouts.mdx`)
title: Layouts
Layout files can be placed in any folder inside the ./app directory. They serve as the frame for the routes contained within that folder, and they can nest inside of each other if you place them in sub-directories.
<RouteTree
routes={[
{
name: '_layout.tsx',
highlight: true,
},
{ name: 'index.tsx', description: 'Matches "/"' },
{
name: 'blog',
children: [
{
name: '_layout.tsx',
highlight: true,
},
{ name: 'index.tsx', description: 'Matches "/blog"' },
{
name: '[slug].tsx',
},
],
},
]}
/>
Layouts must render one of the following to show the matched pages that exist in their directory:
- Slot: Directly show sub-route with no frame.
- Stack: Creates a React Navigation Stack of sub-routes.
- Tabs: Creates a React Navigation BottomTabs with sub-routes.
- Drawer: Creates a React Navigation Drawer with sub-routes.
On web, Stack, Tabs, and Drawer render headless by default: just the matched route, no header, no tab bar, no sidebar. See the headless web navigators guide for how to build your own web chrome.
This looks something like this at the simplest:
import { Slot } from 'one'
export default function Layout() {
return <Slot />
}Layout Loaders
Layouts can export a loader function just like pages. This is useful for fetching data that's shared across all routes in that layout:
import { useLoader, Slot } from 'one'
export async function loader() {
return {
navItems: await fetchDocsNavigation(),
version: await fetchCurrentVersion(),
}
}
export default function DocsLayout() {
const data = useLoader(loader)
return (
<div>
<Sidebar items={data.navItems} version={data.version} />
<main>
<Slot />
</main>
</div>
)
}Layout loaders run in parallel with page loaders for optimal performance. All matched route loaders (root layout → nested layouts → page) execute simultaneously.
Accessing Page Data from a Layout
Use useMatches to access the current page's loader data from a layout:
import { useMatches, Slot } from 'one'
export default function DocsLayout() {
const matches = useMatches()
const pageMatch = matches[matches.length - 1]
// loaderData is unknown, so narrow it before reading fields
const { headings } = (pageMatch?.loaderData ?? {}) as { headings?: string[] }
return (
<div>
<main>
<Slot />
</main>
<TableOfContents headings={headings} />
</div>
)
}See useMatches for more details.
Layout Loader Caching
On client-side navigation, layout loaders do not re-run - their data is cached from the initial page load. Only the destination page's loader runs. This is intentional for performance, as layout data (navigation items, site config) rarely changes.
When layout loaders DO re-run:
- Full page loads (initial visit, hard refresh)
- SSR page requests (each request runs all matched loaders on server)
- After calling
window.location.reload()
When layout loaders DON'T re-run:
- Client-side navigation via
<Link>oruseRouter().push()
If you need fresh layout data, you can trigger a full page navigation instead of client-side navigation, or store frequently-changing data in the page loader and access it via useMatches().
If you need to access the current route params in a layout, you can use the useActiveParams hook.
Root Layout
The root layout on web controls the entire page, from <html> on down. You can optionally return html, body, and head elements in your app/_layout.tsx and they will "just work" - on web, it will be output, and on native it won't so as to avoid rendering errors:
import { Slot } from 'one'
export default function Layout() {
return (
<html lang="en-US">
<head>
<meta charSet="utf-8" />
</head>
<body>
<Slot />
</body>
</html>
)
}Note - you should have both a lang property on your html tag, and a <meta charSet /> tag in your head. Otherwise, React can have hydration re-rendering issues.
Layout Render Modes
Layouts support render mode suffixes just like pages. This is especially useful for the root layout:
app/
├── _layout+ssg.tsx # Pre-rendered at build time
├── _layout+ssr.tsx # Rendered per-request on server
└── _layout.tsx # Client-rendered (default)When to use layout render modes:
_layout+ssg.tsx- Best for static navigation, site chrome, and head tags. The layout HTML is generated once at build time and served instantly for all pages._layout+ssr.tsx- Use when your layout needs dynamic data per-request (e.g., auth state in the nav, user-specific content). The layout loader runs on each request._layout.tsx(no suffix) - Client-rendered. Use when the layout has no server requirements.
SPA pages with SSG/SSR layouts:
When an SPA page has a parent layout with +ssg or +ssr render mode, One automatically renders the layout shell on the server. This gives users immediate visual content (navigation, sidebar, head tags, CSS) while page content remains client-rendered.
import { Slot } from 'one'
export async function loader() {
return {
navItems: ['Home', 'About', 'Contact'],
}
}
export default function RootLayout() {
return (
<html>
<head>
<meta charSet="utf-8" />
<title>My App</title>
</head>
<body>
<nav>{/* Navigation renders immediately */}</nav>
<main>
<Slot /> {/* SPA page content loads after JS */}
</main>
</body>
</html>
)
}With this setup, even dashboard+spa.tsx pages show the nav instantly - only the main content area waits for JavaScript.
useServerHeadInsertion
The root _layout.tsx can use a special hook that allows it to insert tags into your <head /> tag after server rendering finishes. This is an edge-case that is mostly only useful for CSS-in-JS libraries. You can use it like so:
import { Slot, useServerHeadInsertion } from 'one'
export default function Layout() {
useServerHeadInsertion(() => {
// this will run after the entire page renders
// return a single React.ReactElement that will be spliced into your <head />
return <style>{renderCSS()}</style>
})
return (
<html>
<Slot />
</html>
)
}Groups and layouts
If you'd like to logically group together some pages without creating a sub-route, you can use a group folder by naming it like so:
<RouteTree
routes={[
{ name: '_layout.tsx', description: 'Wraps all files in this directory and below' },
{ name: 'index.tsx', description: 'Matches "/"' },
{
name: '(blog)',
highlight: true,
children: [
{ name: '_layout.tsx', description: 'A custom sub-layout for all /blog pages' },
{ name: 'blog.tsx', description: 'Matches "/blog"' },
{
name: '[slug].tsx',
},
],
},
]}
/>
Groups let you add extra layouts. If done right, this gives you a lot of control over how your app feels.
Nested layouts example
A common pattern that apps have is something like Twitter/X, where you have bottom tabs for your "top level" views, but then on some of the bottom tab sections, you want to have a Stack that remembers its state inside just that tab.
This pattern can be incredibly verbose to link together with React Navigation, and takes a bit of tinkering to figure out with One. Since it is common and a useful example, lets walk through how you'd build on using One's file system routing.
Here's our file structure:
<RouteTree
routes={[
{ name: '_layout.tsx', description: 'Where our Tabs layout is defined' },
{ name: 'notifications.tsx', description: 'Matches "/notifications"' },
{ name: 'profile.tsx', description: 'Matches "/profile"' },
{
name: '(feed)',
highlight: true,
children: [
{ name: '_layout.tsx', description: 'Where our Stack layout is defined' },
{ name: 'index.tsx', description: 'Matches "/"' },
{ name: 'post-[id].tsx', description: 'Matches routes like "/post-123"' },
],
},
]}
/>
The top level Layout will define our tabs:
import { Platform } from 'react-native'
export function RootLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="(feed)"
options={{
title: 'Feed',
tabBarIcon: Platform.select({
ios: { type: 'sfSymbol', name: 'newspaper' },
android: { type: 'materialSymbol', name: 'newspaper' },
}),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
tabBarIcon: Platform.select({
ios: { type: 'sfSymbol', name: 'bell' },
android: { type: 'materialSymbol', name: 'notifications' },
}),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: Platform.select({
ios: { type: 'sfSymbol', name: 'person' },
android: { type: 'materialSymbol', name: 'person' },
}),
}}
/>
</Tabs>
)
}This will set us up with three bottom tabs: Feed, Notifications, and Profile. The Notifications and Profile tabs for now will just show their content directly, but inside of the Feed tab, we want to show a stack.
We set up the stack in (feed)/_layout.tsx:
import { Slot, Stack } from 'one'
export default function FeedLayout() {
return (
<>
{typeof window !== 'undefined' ? (
<Slot />
) : (
<Stack>
<Stack.Screen name="index" options={{ title: 'Feed' }} />
<Stack.Screen name="post-[id]" options={{ title: 'Post' }} />
</Stack>
)}
</>
)
}One thing we're showing here is that the layout is diverging between web and native. On web, we are showing a Slot, while on Native we show a Stack. This is because browsers feel better without stacks - the native back/forward button serves as our stack controller.
On Native we are defining the configuration for each sub-screen with the Stack.Screen component. The Stack component is a React Navigation native stack navigator and nothing more, it accepts all the props you'd expect.
You'll notice we are matching the name to the file names names of the sub-routes, without the .tsx extension.
This will get you a nice Stack-inside-Tabs pattern that is common on native apps, all with just two layouts and a few routes.
Limitations
Note that useParams won't work in layouts, as they are never nested inside a route. Instead, you can use useActiveParams.
Apps Onestack.Dev Data Docs Routing Loader (`apps/onestack.dev/data/docs/routing-loader.mdx`)
title: 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.
refetchLoader
Re-run the loader for a path imperatively, outside of React. This is useful in event handlers or background tasks where hooks are not available:
import { refetchLoader } from 'one'
async function refreshUser(id: string) {
await refetchLoader(`/users/${id}`)
}The promise resolves when the refetch completes. Components reading that loader through useLoader or useLoaderState re-render with the fresh data.
onClientLoaderResolve
Runs once on the client after hydration preloads finish and before the first render. Register it in your root _layout.tsx when you need to observe the initial loader payload from outside React:
import { onClientLoaderResolve } from 'one'
onClientLoaderResolve(({ loaderData, loaderProps }) => {
console.log('hydrated loader data for', loaderProps?.path, loaderData)
})It must be registered before the client finishes setup, so call it at the top level of your root layout module. Calling it after it already ran throws.
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 }
// loaderData is unknown, so narrow it before reading fields
return (
<nav>
{matches.map(match => {
const { title } = (match.loaderData ?? {}) as { title?: string }
return (
<a key={match.routeId} href={match.pathname}>
{title ?? match.routeId}
</a>
)
})}
</nav>
)
}See useMatches for detailed documentation.
Layout Loaders
Layouts can export a loader function just like pages:
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:
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:
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.
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:
- The server detects the redirect response from the loader
- Instead of returning a raw 302, it transforms the response into a JS module containing redirect metadata
- The client receives the metadata and detects the redirect signal before rendering anything
- The client navigates to the redirect target using a
REPLACEaction — 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
+ssrroutes 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.
Loader Cache
Routes can export a loaderCache function to deduplicate concurrent SSR loader calls. When multiple requests hit the same route at the same time, only one loader execution runs and the result is shared across all waiting requests.
Basic usage
Return a string key from loaderCache to coalesce concurrent requests with matching keys:
import { useLoader } from 'one'
import { db } from '~/lib/db'
// concurrent requests with the same id share one loader call
export const loaderCache = (params: { id: string }) => params.id
export async function loader({ params }) {
const card = await db.getCardWithListings(params.id)
return { card }
}
export default function CardPage() {
const { card } = useLoader(loader)
return <CardDetail card={card} />
}Without ttl, only truly concurrent requests are coalesced — the cache entry is cleaned up immediately after the loader resolves.
TTL-based caching
Return an object with key and ttl to cache the result for a time window (in milliseconds):
export const loaderCache = (params: { id: string }) => ({
key: params.id,
ttl: 100, // cache for 100ms
})With ttl, subsequent requests within the TTL window reuse the cached result without running the loader again.
Auth-aware cache keys
loaderCache receives (params, request) so you can include auth headers or other request data in the key:
export const loaderCache = (params, request) => {
const userId = request?.headers.get('x-user-id') || 'anon'
return `${params.id}:${userId}`
}When to use loaderCache
- Expensive queries under load — routes with heavy database queries that get concurrent traffic (popular pages, viral content)
- Identical results across users — routes where the loader result doesn't depend on auth. For auth-dependent loaders, include user identity in the key
- High concurrency — in benchmarks with 50 concurrent connections to the same route,
loaderCacheimproved throughput from ~570 req/sec to ~2,000 req/sec (3.5x)
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, validateParams throws a ParamValidationError (with params and issues fields), navigation is blocked and an error is shown in the Error Panel (Alt+E).
For Zod schemas, wrap with zodParamValidator so the validator keeps its inferred output type:
import { zodParamValidator } from 'one'
import { z } from 'zod'
export const validateParams = zodParamValidator(z.object({
id: z.string().uuid('Invalid ID format'),
}))The related types are exported from one: ParamValidator, InferParamInput, InferParamOutput, and ParamValidationError.
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} />
}The function receives { params, search, pathname, href } (see ValidateRouteProps) and must return { valid: true } or { valid: false, error, details? } (see ValidationResult). It can also return nothing, which counts as valid. The related handler type is RouteValidationFn.
validateParams vs validateRoute
Both can validate routes, but they serve different purposes:
| Feature | validateParams | validateRoute |
|---|---|---|
| Runs on | Client | Client |
| Use case | Schema validation | Async 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'
// error is set only when status is 'error'
if (status === 'validating') {
return <Spinner />
}
if (status === 'error') {
return <Alert>{error?.message}</Alert>
}
return null
}The state also carries lastValidatedHref, the href that was last validated. The error is a ParamValidationError when validateParams fails, or a RouteValidationError (with a details field) when validateRoute returns { valid: false }.
Apps Onestack.Dev Data Docs Routing Middlewares (`apps/onestack.dev/data/docs/routing-middlewares.mdx`)
title: Middlewares
Middlewares let you intercept requests before they reach your routes. They're useful for authentication, logging, redirects, adding headers, and more.
Place a _middleware.ts file anywhere inside your app directory to create a middleware.
File Structure
<RouteTree
routes={[
{ name: '_layout.tsx', description: 'Wraps all files in this directory and below' },
{ name: '_middleware.ts', description: 'Middleware for all "/" routes' },
{ name: 'index.tsx', description: 'Matches "/"' },
{
name: 'blog',
children: [
{ name: '_middleware.ts', description: 'Middleware for all "/blog" routes' },
{ name: 'index.tsx', description: 'Matches "/blog"' },
],
},
{
name: 'api',
children: [
{ name: '_middleware.ts', description: 'Middleware for all "/api" routes' },
{ name: 'users+api.ts', description: 'API route at "/api/users"' },
],
},
]}
/>
Middlewares are hierarchical - they run from the root down to the most specific matching route. In the example above, a request to /blog would run:
app/_middleware.tsfirstapp/blog/_middleware.tssecond- Then the route handler
Basic Usage
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next }) => {
console.log(`[${new Date().toISOString()}] ${request.method} ${request.url}`)
const response = await next()
return response
})API Reference
The createMiddleware function accepts a middleware handler and provides type safety:
import { createMiddleware, type Middleware } from 'one'
const handler: Middleware = async ({ request, next, context }) => {
// request: the standard Web API Request
// next: calls the next middleware in the chain, then the route
// context: a mutable object shared across all middlewares in the chain
return await next()
}
export default createMiddleware(handler)Props
| Prop | Type | Description |
|---|---|---|
request |
Request |
The incoming request object |
next |
() => Promise<Response | void | null> |
Calls the next middleware in the chain, then the route |
context |
MiddlewareContext |
Mutable object shared across all middlewares in the chain |
MiddlewareContext accepts any key. Declare your own keys on it to type them, as shown in Sharing Data with Context.
Return Values
| Return | Effect |
|---|---|
Response |
Stops the chain and returns this response immediately |
await next() |
Continues to the next middleware/route and returns its response |
null or undefined |
Continues the chain (same as calling next()) |
Common Patterns
Redirects
Redirect users based on conditions:
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next }) => {
const url = new URL(request.url)
// Redirect old paths to new paths
if (url.pathname === '/old-page') {
return Response.redirect(new URL('/new-page', url.origin), 301)
}
// Redirect to HTTPS in production
if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
url.protocol = 'https:'
return Response.redirect(url, 301)
}
return await next()
})Adding Headers
Modify response headers for CORS, caching, security, etc:
import { createMiddleware, setResponseHeaders } from 'one'
export default createMiddleware(async ({ request, next }) => {
// Add CORS headers using setResponseHeaders helper
setResponseHeaders((headers) => {
headers.set('Access-Control-Allow-Origin', '*')
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
})
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204 })
}
return await next()
})Or modify the response directly:
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next }) => {
const response = await next()
// Add security headers to all responses
response?.headers.set('X-Content-Type-Options', 'nosniff')
response?.headers.set('X-Frame-Options', 'DENY')
return response
})Logging
Log requests for debugging or analytics:
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next }) => {
const start = Date.now()
const url = new URL(request.url)
const response = await next()
const duration = Date.now() - start
console.log(`${request.method} ${url.pathname} - ${response?.status} (${duration}ms)`)
return response
})Sharing Data with Context
Pass data between the middlewares in a request's chain. context starts empty for each request and accepts any key; declare the keys you use on MiddlewareContext to type them:
import { createMiddleware } from 'one'
declare module 'one' {
interface MiddlewareContext {
requestId?: string
timestamp?: number
user?: { id: string }
}
}
export default createMiddleware(async ({ request, next, context }) => {
// Parse and validate request data once
context.requestId = crypto.randomUUID()
context.timestamp = Date.now()
// Add user info if authenticated
const token = request.headers.get('authorization')
if (token) {
context.user = await getUserFromToken(token)
}
return await next()
})Access the context in nested middlewares:
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next, context }) => {
// Context from parent middleware is available
console.log(`Request ${context.requestId} from user ${context.user?.id}`)
return await next()
})Early Response (Interception)
Return a response immediately without calling next() to intercept the request:
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next }) => {
const url = new URL(request.url)
// Return cached response for specific paths
if (url.pathname === '/api/health') {
return Response.json({ status: 'ok', timestamp: Date.now() })
}
// Block certain paths
if (url.pathname.startsWith('/admin') && !isAdmin(request)) {
return new Response('Forbidden', { status: 403 })
}
return await next()
})Post-Processing Responses
Modify responses after the route has processed:
import { createMiddleware } from 'one'
export default createMiddleware(async ({ request, next }) => {
const response = await next()
// Handle 404s with a custom response
if (!response || response.status === 404) {
return Response.json(
{ error: 'Not found', path: new URL(request.url).pathname },
{ status: 404 }
)
}
return response
})Execution Order
Middlewares execute in order from root to leaf:
Request to /blog/post/123
↓
1. app/_middleware.ts (runs first)
↓
2. app/blog/_middleware.ts (runs second)
↓
3. app/blog/post/_middleware.ts (runs third)
↓
4. Route handler executes
↓
Responses bubble back up through each middlewareEach middleware can:
- Intercept: Return a response early without calling
next() - Pass through: Call
next()to continue the chain - Post-process: Modify the response after calling
next()
Platform Support
| Platform | Support |
|---|---|
| Node.js server | Full support |
| Vercel | Full support |
| Cloudflare Workers | Full support |
| Static export | Not applicable |
| React Native | Not supported (server-only) |
Middlewares are server-only and run on every request. They do not run during static site generation or on native platforms.
Middleware vs Loader Redirects
Middleware redirects and loader redirects serve different purposes:
| Middleware Redirects | Loader Redirects | |
|---|---|---|
| Best for | URL rewrites, HTTPS enforcement, path-level access control | Protecting page data, auth guards for SSR pages |
| Runs on | Every request before the route | When loader data is fetched |
| Client-side nav | Not involved (middleware only runs on server requests) | Server intercepts redirect and sends metadata to client, preventing flash of protected content |
| Data protection | Returns early before route processes | Prevents sensitive loader data from reaching the client |
For protecting SSR routes with auth, prefer loader redirects — they handle both direct page loads and client-side <Link> navigation, ensuring no unauthorized data leaks. Use middleware redirects for request-level concerns like URL rewrites, HTTPS enforcement, and API route protection.
See the Loader Redirects guide for a complete walkthrough.
Related
- Loaders - Load data for routes
- Loader Redirects - Protecting routes with loader redirects
- API Routes - Create API endpoints
- setResponseHeaders - Helper for setting headers
Apps Onestack.Dev Data Docs Routing Navigation (`apps/onestack.dev/data/docs/routing-navigation.mdx`)
title: Navigation
When you want to move between a page, you have a few options. Examples on this page will reference this routing structure:
<RouteTree
routes={[
{ name: '_layout.tsx', description: 'Wraps all files in this directory and below' },
{ name: 'index.tsx', description: 'Matches "/"' },
{ name: 'blog.tsx', children: [
{ name: 'index.tsx', description: 'Matches "/blog"' },
{ name: '[slug].tsx', description: 'Matches a single sub-path of "/blog", like "/blog/hello"' }
] },
]}
/>
Link
You can use the Link component as follows:
import { Link } from 'one'
export default function HomePage() {
return (
<>
<Link href="/blog">
Go to the blog
</Link>
<Link href="/blog/introducing-one">
Go to the Introducing One article
</Link>
</>
)
}Link takes an href typed from your file system routes (or a plain string if you turn off typed file system routing), plus replace, push, asChild, className, onPress, mask, and the web-only anchor props target, rel and download. See the full prop list on the <Link /> page.
useLinkTo
This hook will generate both an href string and an onPress function, see the useLinkTo docs for how to use it.
useRouter
This hook returns a static object used for routing. It will never update, and is only used for imperatively controlling the router. Useful for programmatically controlling the routing, and choosing between a push or replace.
See the useRouter documentation page for more.
removeParams
removeParams strips named params from every route in a React Navigation state, at every nesting level. It returns a new state object and leaves the input untouched:
import { removeParams } from 'one'
// drop one-off params before persisting or restoring navigation state
const cleanState = removeParams(state, ['previewToken'])Apps Onestack.Dev Data Docs Routing Typed Routes (`apps/onestack.dev/data/docs/routing-typed-routes.mdx`)
title: Typed Routes
One provides full TypeScript support for route parameters and loader props, automatically generating types from your file system routes.
Overview
When you create route files with dynamic segments like [slug] or [...rest], One automatically generates TypeScript types that make your route parameters fully type-safe.
Generated Types
All route types are generated in app/routes.d.ts. This is the shape the generator emits (including the import at the top, which the SingleRoutePart reference needs):
import type { OneRouter } from 'one'
declare module 'one' {
export namespace OneRouter {
export interface __routes<T extends string = string> extends Record<string, unknown> {
StaticRoutes: '/' | '/about' | '/docs'
DynamicRoutes: `/docs/${OneRouter.SingleRoutePart<T>}`
DynamicRouteTemplate: '/docs/[slug]'
IsTyped: true
RouteTypes: {
'/docs/[slug]': RouteInfo<{ slug: string }>
}
}
}
}
/**
* Helper type for route information
*/
type RouteInfo<Params = Record<string, never>> = {
Params: Params
LoaderProps: { path: string; search?: string; subdomain?: string; params: Params; request?: Request }
}SingleRoutePart is exported from the OneRouter namespace in one, and matches a single path segment. Do not hand-edit this file, regenerate it with npx one generate-routes instead.
Using Typed Routes
With `createRoute` Helper
The recommended approach is to use the createRoute helper, which provides fully typed methods:
import { createRoute } from 'one'
const route = createRoute<'/docs/[slug]'>()
export const loader = route.createLoader(async ({ params }) => {
// params.slug is string - fully typed!
return { doc: await fetchDoc(params.slug) }
})
export default function DocPage() {
const params = route.useParams()
// params.slug is string - fully typed!
return <div>Viewing: {params.slug}</div>
}Manual Type Usage
You can also use the RouteType helper to get types for any route:
import type { RouteType } from 'one'
type Route = RouteType<'/docs/[slug]'>
export const loader = ({ params }: Route['LoaderProps']) => {
// params.slug is fully typed!
return { doc: await fetchDoc(params.slug) }
}Auto-Generation
Enable the experimental typedRoutesGeneration option to automatically insert type helpers into your route files:
export default {
plugins: [
one({
router: {
experimental: {
typedRoutesGeneration: 'runtime', // or 'type'
},
},
}),
],
}Generation Modes
false(default): No auto-generation, manually add types yourself'type': Auto-inserts type-only helpers:
import type { RouteType } from 'one'
type Route = RouteType<'/your/[route]'>'runtime': Auto-inserts runtime helpers:
import { createRoute } from 'one'
const route = createRoute<'/your/[route]'>()The insertion happens automatically when route files are created or modified, with proper spacing and without modifying your existing loader code.
CLI Command
Generate route types manually using the One CLI:
npx one generate-routes # Generate types only
npx one generate-routes --typed=runtime # Generate + inject runtime helpers
npx one generate-routes --typed=type # Generate + inject type helpersNote: The CLI command requires --typed to inject helpers. The typedRoutesGeneration config option works automatically during development (watches files and injects on changes), while the CLI flag gives you manual control when needed.
Complex Routes
Multiple Parameters
import { createRoute } from 'one'
const route = createRoute<'/blog/[category]/[year]/[slug]'>()
export const loader = route.createLoader(({ params }) => {
// All params are fully typed!
const { category, year, slug } = params
// category: string, year: string, slug: string
return { post: await fetchPost(category, year, slug) }
})Catch-All Routes
import { createRoute } from 'one'
const route = createRoute<'/files/[...path]'>()
export const loader = route.createLoader(({ params }) => {
// params.path is string[] - fully typed!
const filePath = params.path.join('/')
return { file: await fetchFile(filePath) }
})Benefits
- Type Safety: Catch route parameter errors at compile time
- Better DX: Auto-complete for route params in loaders
- Clean Intellisense: Shows
{ slug: string }instead of complex type aliases - Refactor-Friendly: Rename routes and TypeScript catches all usages
- Zero Runtime Cost: Types are erased during build
How It Works
- One scans your
appdirectory for route files - Extracts dynamic segments like
[slug]and[...rest] - Generates TypeScript types in
app/routes.d.ts - TypeScript provides autocomplete and type checking
The RouteType helper looks up the generated types from routes.d.ts and provides dot-notation access to Params and LoaderProps.
Environment Variables
One also provides TypeScript types for preset environment variables. Add this to any .d.ts file in your project:
/// <reference types="one/env" />This gives you typed access to import.meta.env.VITE_NATIVE, process.env.VITE_ENVIRONMENT, and other One environment variables.
Apps Onestack.Dev Data Docs Routing (`apps/onestack.dev/data/docs/routing.mdx`)
title: Routing
The app directory is where One handles universal file system routing. Every file you create in it will be turned into a route, except for files named _layout or _middleware (or files that match patterns in your ignoredRouteFiles configuration, if you have that). One only supports file system routes at this time, but we have a discussion for those who want to help us figure out a configuration-based approach.
Here's an example:
<RouteTree
routes={[
{ name: '_layout.tsx', description: 'Wraps all files in this directory and below' },
{ name: 'index.tsx', description: 'Matches "/"' },
{ name: 'blog', children: [
{ name: 'index.tsx', description: 'Matches "/blog"' },
{ name: '[slug].tsx', description: 'Matches a single sub-path of "/blog", like "/blog/hello"' },
{ name: '[...rest].tsx', description: 'Matches all sub-paths, like "/blog/hello/world"' }
] },
]}
/>
One routes support the popular feature of nested layouts, via _layout.tsx files, and parallel render-as-you-fetch data loading via export loader.
Route Structure
All .tsx files in the app dir are used to match your routes, besides layouts. Each individual file is referred to as a "page" to disambiguate them from the term "route," which we use to refer to the actual URL state in a browser on web (or React Navigation state on native).
All pages must export a React component to be rendered. You can choose between two ways to export it: export default or plain export. If you don't default export, we look for the first export that is capitalized. The reason we support this is simple - the React Refresh library doesn't support hot reloading default exports. We're looking into modifying it to support them for route files, but until then you may want to simply use export to maintain a nice hot reloading experience.
Simple Routes
A simple page that matches the / route and will render on native and web:
import { Text } from 'react-native'
export default function HomePage() {
return (
<Text>Hello world</Text>
)
}If you are targeting web-only, you may use HTML elements:
export default function HomePage() {
return (
<div>Hello world</div>
)
}All index files match against /, so app/index.tsx matches to your root / route. You can give your page a name as well, for example app/about.tsx will match the /about route.
Dynamic Routes
The segments of a route, for example /hello/world, are referred to as "route parameters" or just "params", where "hello" and "world" are each a single parameter. Pages can match against parameters dynamically in two ways: matching a single parameter, or matching all parameters below them.
Single Parameter Routes
One uses brackets to match against a single dynamic parameter: ./blog/[slug].tsx matches /blog/post-one and /blog/post-two:
<RouteTree
routes={[
{ name: 'blog.tsx', children: [
{ name: 'index.tsx', description: Matches "/blog" },
{ name: '[slug].tsx', description: Matches a single sub-path of "/blog", like "/blog/hello" }
] },
]}
/>
One will match ./blog/[slug].tsx to any /blog/* route, and pass in the parameters to the route as follows:
import { useParams } from 'one'
export function loader({ params }) {
// params.slug will be a string matching the URL parameter
}
export default function BlogPostPage() {
const params = useParams()
// params.slug will be a string matching the URL parameter
}Rest Parameter Routes
Much like JavaScript supports rest parameters, One supports rest parameter routes which are defined using the [...rest].tsx syntax.
<RouteTree
routes={[
{ name: 'catalog.tsx', children: [
{ name: 'index.tsx', description: Matches "/catalog" },
{ name: '[...rest].tsx', description: Matches all sub-paths of "/catalog", like "/catalog/a/b/c" }
] },
]}
/>
In the case where a user navigates to /catalog/a/b/c, the [...rest].tsx route would receive a params prop as follows:
import { useParams } from 'one'
export function loader({ params }) {
// params.rest is an array ['a', 'b', 'c']
}
export default function BlogPostPage() {
const params = useParams()
// params.rest is an array ['a', 'b', 'c']
}Not found routes
To have a custom 404 page, you can create a +not-found.tsx route in any folder. It will act as the same as a [...rest].tsx route in that it will catch all sub-routes for that folder.
Simply export a React component (default or named) from your not found page and it will render with a 404 status code.
<RouteTree
routes={[
{ name: 'catalog.tsx', children: [
{ name: 'index.tsx', description: Matches "/catalog" },
{ name: '+not-found.tsx', description: Matches all non-matching sub-paths of "/catalog" with a 404 status code }
] },
]}
/>
Render Modes
You can choose a rendering strategy on a per-page basis using a filename suffix. For more on these modes, see the next documentation page, Render Modes.
route+ssg.tsx- Matches/route, but will render the page as a SSG route.route+spa.tsx- Matches/route, but will render the page as a SPA route.route+ssr.tsx- Matches/route, but will render the page as a SSR route.route+api.ts- Matches/route, but will render the page as an API route.
Layouts also support render mode suffixes (_layout+ssg.tsx, _layout+ssr.tsx). This is especially useful for SPA-heavy apps - when a parent layout has SSG or SSR mode, the layout shell renders on the server while page content remains client-rendered. See Layout Render Modes for details.
Routing per-platform
You can target a specific platform using the same specific extension convention as React Native - ie, using .web.tsx, .native.tsx, .ios.tsx, or .android.tsx as the last part of the filename.
This lets you diverge the routing based on the platform. For example:
<RouteTree
routes={[
{ name: 'index.tsx', description: Matches "/" on native" },
{ name: 'index.web.tsx', description: Matches "/" on web },
{ name: 'blog.web.tsx', description: Matches "/blog" on web, on native there will be no route },
]}
/>
Groups
You can define a group by naming a folder with surrounding parenthesis:
<RouteTree
routes={[
{ name: '_layout.tsx', description: '' },
{ name: '(blog)', children: [
{ name: '_layout.tsx', description: 'This layout will nest inside the above layout' },
{ name: 'blog.tsx', description: 'Matches "/blog"' },
{ name: '[slug].tsx', description: 'Matches a single sub-path of "/blog", like "/blog/hello"' },
] },
]}
/>
Groups are useful for a couple reasons:
- They're useful to organize similar things without forcing you to nest URL segments.
- They let you nest layouts, also without nesting segments.
Types
One generates types for your routes, so that you get type checking on a variety of things like the Link component.
The types are automatically generated into app/routes.d.ts during development. This file is committed to your repository and TypeScript automatically discovers it.
You can also manually regenerate route types at any time:
yarn one generate-routesIf your app directory is not named app, you can specify it:
yarn one generate-routes --appDir=srcApps Onestack.Dev Data Docs Status (`apps/onestack.dev/data/docs/status.mdx`)
title: Status
This is a live document covering the high level status of the various parts of One. We don't cover all areas, just high level stable, and all developing or unstable sub-areas.
Broadly: web and native are stable.
Stages, from least to most stable
· · · - Features in
or later are available to use.
OS Support
- Mac
- Linux
- Windows
(not officially supported; the CLI prints a warning)
Web
Web is production ready.
- Local development
- Production builds
- Not Found
Native
One bundles native apps with Rolldown, the same bundler as web, in development and production.
- Local development
(HMR via React Refresh) - Production builds
(tree shaking) - Assets
- Symbolication
Routing
SSG, SPA, and SSR are all stable.
- SSG
- SPA
- SSR
Deployment
- Node (one serve)
- Vercel
- Cloudflare
Components
- Slot
- Stack
- Tabs
- Drawer
One Native
See the Native docs for details.
- Native components (SwiftUI, Compose, shared)
- Platform APIs
- Swift and Kotlin imports
- Over-the-air updates
- Color API
- Zoom Transitions
- Toolbar
- Menu Actions
- SplitView
--- METRICS ---
- Files Extracted: 95
- Estimated Token Budget: ~113652 tokens
- Canonical Reference: https://codewiki.google/github.com/onejs/one