one Documentation

One lets you target React web and React Native with a single Vite plugin. Everything you need to build great websites and apps with unified routing.

4,610 stars TypeScript TypeScript 95 files · ~113,727 tokens #local-first#react#react-native#vite
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy one on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name one --restart always onejs/one:latest

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

tsx
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:

tsx
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:

sh
gh workflow run release.yml --repo onejs/one --ref main -f release=minor

A 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:

sh
bun scripts/release.ts --minor --ci --dirty \
  --skip-publish --skip-push --skip-tests --skip-native-tests

Run 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:

sh
gh workflow run release.yml --repo onejs/one --ref <branch> -f release=canary

Manual canaries use a queue per branch, separate from push canaries. A newer
v2-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:

sh
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:

sh
gh workflow run release.yml --repo onejs/one --ref v2-beta -f release=beta

The 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 npm
beta 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:

sh
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:

bash
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.

bash
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 config

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.

one build

Build your app for production. See one build for details.

bash
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 build

one serve

Serve a built web app for production. See one serve for details.

bash
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 workers

You must run one build before serve.

Programmatic Usage

You can also run your production server programmatically:

tsx
import { serve } from 'one/serve'

await serve()

Which takes options:

tsx
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.

bash
one prebuild [options]

--platform ios|android   # which native project to generate
--no-install             # skip installing native dependencies

Apps 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.

bash
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 UDID

one 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.

bash
one run:android

one clean

Remove build folders and caches for the current project.

bash
one clean

one 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.

bash
one patch [options]

--force    # re-apply all patches, even if already applied

one generate-routes

Generate route type definitions (app/routes.d.ts). See Typed Routes for details.

bash
one generate-routes [options]

--appDir <path>        # path to app directory (default: "app")
--typed type|runtime    # auto-generate route helpers alongside the types

one typegen

Alias for one generate-routes. Takes the same options.

bash
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.

bash
one metro-eject [options]

--force    # overwrite existing files

one daemon

Multi-app development server proxy. Coordinates several One dev servers behind one port.

bash
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.

bash
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 map

Apps 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:

tsx filename=vite.config.ts
{
  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 .js files.
  • Including JSX syntax in .js files instead of .jsx.
  • Releasing packages as ESM-only without proper declarations in package.json (e.g., missing "type": "module") or appropriate .mjs extensions.
  • 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 file following with a syntax error parsing something like import 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:

tsx filename=vite.config.ts
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.

We encourage you to [open an issue](https://github.com/onejs/one/issues/new) on our GitHub repository if you encounter similar issues, as your feedback helps us improve One's compatibility with React Native packages.

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.

If appropriate, it's also nice to reach out to the maintainers of those packages to help improve their compatibility with non-Metro bundlers.

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:

tsx
// ❌ 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:

tsx filename=vite.config.ts
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

tsx filename="_layout.tsx"
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { Drawer } from 'one/drawer'

export default function Layout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Drawer />
    </GestureHandlerRootView>
  )
}
The `GestureHandlerRootView` wrapper is required for gesture handling to work properly on native platforms.

With Screen Options

You can customize the drawer appearance and behavior using screenOptions:

tsx filename="_layout.tsx"
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:

tsx
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:

tsx
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:

bash
npm install @react-navigation/drawer react-native-gesture-handler react-native-reanimated

These 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

tsx filename=app/blog/[slug].tsx
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
tsx
<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:

tsx filename=app/products/[id].tsx
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:

tsx
<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:

tsx filename=features/headinfo.tsx
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:

tsx
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 */}
    </>
  )
}


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:

text
/* 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.

tsx
import { LoadProgressBar, Slot } from 'one'

export default function Layout() {
  return (
    <>
      <LoadProgressBar />
      <Slot />
    </>
  )
}

Props

tsx
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

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

tsx
<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:

tsx
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:

tsx
<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.


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.

tsx
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:

tsx
<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:

tsx
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:

tsx
<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:

  • public is always available
  • dashboard requires isLoggedIn to be true
  • premium-features requires both isLoggedIn AND isPremium to 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:

tsx
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

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

tsx
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>/null while auth state settles remounts <Redirect> repeatedly, and each remount is a fresh instance. Because router.replace is async (it preloads the target route before dispatching), every remount would otherwise fire another concurrent navigation and throw Maximum 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

tsx filename=app/_layout.tsx
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

tsx
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

tsx
<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:

tsx
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.

  • Layouts - Using SafeAreaView in layouts
  • Stack - Stack navigation with safe areas

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-id when 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:

tsx filename=app/_layout.tsx
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

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

tsx filename=app/dashboard/_layout.tsx
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:

tsx
import { useScrollGroup } from 'one'

function MyLayout() {
  // Create a scroll group for a specific path
  useScrollGroup('/products')

  return <Slot />
}

Disable scroll behavior for an individual navigation by passing scroll: false to the router:

tsx
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

  1. On navigation start: Current scroll position is saved to sessionStorage keyed by pathname
  2. 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

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:

tsx filename=app/feed/_layout.tsx
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:

tsx filename=app/(tabs)/_layout.tsx
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:

tsx
router.push('/modal', { scroll: false })
  • 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.

tsx
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:

tsx
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:

tsx
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.

tsx
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:

tsx
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:

tsx
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 entirely
  • blurEffect - iOS blur effect ('regular', 'prominent', 'systemMaterial', etc.)
  • asChild - Render a completely custom header component
  • style - Style with backgroundColor, shadowColor (set to 'transparent' to hide)
  • largeStyle - Style for large title mode

Stack.Header.Title

Configure the header title:

tsx
<Stack.Header.Title large>My Title</Stack.Header.Title>

Props:

  • children - The title text
  • large - Enable iOS large title mode
  • style - Text style (fontWeight, fontSize, color, textAlign)
  • largeStyle - Text style for large title mode

Stack.Header.BackButton

Configure the back button:

tsx
<Stack.Header.BackButton hidden />
<Stack.Header.BackButton displayMode="minimal">Back</Stack.Header.BackButton>

Props:

  • children - Custom back button text
  • hidden - Hide the back button
  • displayMode - 'default', 'generic', or 'minimal'
  • withMenu - Enable long-press menu on iOS
  • src - Custom back button image
  • style - Back button title style

Stack.Header.Left / Stack.Header.Right

Add custom components to the header:

tsx
<Stack.Header.Left asChild>
  <MenuButton />
</Stack.Header.Left>

<Stack.Header.Right asChild>
  <SettingsButton />
</Stack.Header.Right>

Props:

  • asChild - Required to render custom children
  • children - Your custom component

Add an iOS-style search bar:

tsx
<Stack.Header.SearchBar
  placeholder="Search articles..."
  autoCapitalize="none"
  placement="stacked"
/>

Props:

  • placeholder - Placeholder text
  • autoCapitalize - 'systemDefault', 'none', 'words', 'sentences', 'characters'
  • placement - 'automatic', 'inline', 'stacked', 'integrated', 'integratedButton', 'integratedCentered'
  • hideWhenScrolling - Hide when scrolling
  • obscureBackground - 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:

tsx
<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:

tsx
<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:

tsx
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:

tsx
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:

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:

tsx
<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.

tsx
import { Tabs } from 'one'

export default function Layout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="explore"
        options={{
          title: 'Explore',
        }}
      />
    </Tabs>
  )
}
Adding a new tab route has a bug currently that can cause it to not show up until you re-run your app with `--clean`.

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:

tsx
<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:

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

text
/* 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).

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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':

tsx filename=vite.config.ts
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:

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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:

  • ssg stands 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.

  • spa stands 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

  • ssr stands 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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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:

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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 generateStaticParams output
  • 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.

tsx filename=vite.config.ts
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 detected
  • false: 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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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 that
  • true: Scans and adds all routes as entries
  • false: 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:

tsx filename=vite.config.ts
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:

tsx
autoWarmPlugin('./src/dev/warmDeps.json')

devtools

Development tools to help debug and understand your app. Enabled by default in development mode.

tsx filename=vite.config.ts
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:

  1. Hold Option (Mac) or Alt (Windows/Linux) for 0.8 seconds
  2. Hover over any element to see its source file location
  3. 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.

tsx filename=vite.config.ts
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:

  1. Press Alt+S to toggle the panel
  2. 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
  3. 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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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:

  1. Press Alt+R to toggle the panel
  2. 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:

  1. Press Alt+L to toggle the panel
  2. 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
  3. Visual waterfall shows relative timing of module load vs execution
  4. 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:

  1. Press Alt+P to toggle the panel
  2. 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:

  1. Press Alt+E to toggle the panel
  2. 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:

tsx filename=vite.config.ts
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 UserConfig

Any 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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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:
tsx
import type { RouteType } from 'one'
type Route = RouteType<'/your/[route]'>
  • 'runtime': Auto-inserts runtime helpers:
tsx
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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/CD
  • false: Disable scanning entirely
  • { level, safePatterns }: Object form with level ('warn' or 'error') and safePatterns (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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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.

tsx filename=vite.config.ts
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: When true, 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:

tsx filename=vite.config.ts
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 transforms
  • false: 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: iOS
  • android: 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:

text
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:

  • publicDir to "public"
  • clearScreen to false so we can output the quick actions

We set up defines:

  • __DEV__ to true in development mode, or false otherwise
  • process.env.NODE_ENV

We set up alias based on environment:

  • react-native => react-native-web for 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:

tsx
{
  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:

tsx
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:

text
# 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:5

Events 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:

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

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.url loads 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.
  • actions appear 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.

sh
bun add -d @rozenite/storage-plugin

On the next one dev, pressing j or dt opens DevTools with the Storage panel. One logs the plugin names it found:

text
[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:

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

  1. Add @runtime-inspector/react-native and @runtime-inspector/panel-rozenite to the app.
  2. Import the panel once from the app entry: import '@runtime-inspector/panel-rozenite'.
  3. 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:

ts
// vite.config.ts
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      devtools: false,
    }),
  ],
}

To keep the web tools but change individual features:

ts
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_PORT sets the port for both one dev and one serve. This is equivalent to passing --port on the command line, but works across both commands and can be set in your .env file or deployment config. If --port is also passed, the CLI flag takes precedence.
  • ONE_FORCE_PORT overrides the port for both one dev and one serve, taking precedence over --port and ONE_PORT. Useful for CI or deployment environments where the port must be fixed regardless of other configuration.

Port priority (highest wins):

  1. ONE_FORCE_PORT
  2. --port CLI flag
  3. ONE_PORT
  4. Built-in defaults (8081 for dev, 3000 for serve)

Core Variables

  • ONE_CACHE_KEY is set to a random number for each production build, or stable per dev server run. Useful for cache keys.
  • ONE_APP_NAME is set to your native.app.name setting, falling back to native.key.
  • ONE_SERVER_URL is set in development mode to the current running server URL, like http://0.0.0.0:8081. You are expected to set this yourself for production builds.
  • ONE_DEFAULT_RENDER_MODE is set to either "ssr", "ssg", or "spa" based on your defaultRenderMode setting.
  • ONE_RENDER_MODE is 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_ENVIRONMENT is set to "client" for client-side web, "ssr" for server-side web, or "ios" / "android" for native platforms.
  • VITE_NATIVE is "1" for native platforms (iOS and Android), or "" for web (client and SSR). Uses truthy/falsy strings to avoid polluting process.env types. Useful for tree-shaking native-only code.
  • ONE_PLATFORM is "web", "ios", or "android".
  • TAMAGUI_TARGET is "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_VERSION contains the React Native version string.
  • REACT_NATIVE_PLATFORM is set to "ios" or "android" in native builds.
  • REACT_NATIVE_SERVER_PUBLIC_PORT is 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:

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

tsx
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 ./app folder
  • 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 watchFile for instant content updates during development
  • Enables the patches option 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 .env file into process.env before running anything using dotenv
  • Adds a well-configured tsconfig.json if it doesn't exist
  • Enables ?imagedata imports for automatic image dimensions and blur placeholders
  • Supports .inline.css files 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|android for extensions ts|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 useMatches hook to access all matched route data
  • Adds useMatch to find a specific route's loader data by route ID
  • Adds usePageMatch to get the current page's match
  • Adds a Link component for navigation
  • Adds useSearchParams for 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 useBlocker hook for blocking navigation with confirmation
  • Adds route validation with validateParams and validateRoute
  • Adds loaderCache for 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

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

Extras


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.

tsx filename=features/auth/ctx.tsx
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:

text
features/auth/
├── storage.ts        # Shared interface
├── storage.web.ts    # localStorage or MMKV for web
└── storage.native.ts # MMKV or One.SecureStore for native

This 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:

tsx filename=app/(app)/_layout.tsx
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' },
] },
]}
/>

tsx filename=app/_layout.tsx
import { Slot } from 'one'
import { SessionProvider } from '~/features/auth/ctx'

export default function RootLayout() {
  return (
    <SessionProvider>
      <Slot />
    </SessionProvider>
  )
}
tsx filename=app/(app)/_layout.tsx
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:

tsx filename=app/login.tsx
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>
  )
}
Render `` declaratively from your guard. Don't replace it with a manual `useEffect` + `useRef` + `router.replace`. While auth state settles during a sign-in, the guard re-renders rapidly and may remount ``, but `` de-duplicates the navigation across those remounts. A hand-rolled effect that fires `router.replace` on every render can thrash the navigator into `Maximum update depth exceeded`.

Middleware Authentication

One's middleware runs on the server before your route, making it ideal for auth checks.

Protect API Routes

tsx filename=app/api/_middleware.ts
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.

tsx filename=app/profile+ssr.tsx
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

tsx filename=features/auth/server.ts
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!,
    },
  },
})
tsx filename=app/api/auth/[...rest]+api.ts
import { auth } from '~/features/auth/server'

export const GET = (req: Request) => auth.handler(req)
export const POST = (req: Request) => auth.handler(req)
tsx filename=app/login.tsx
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

tsx filename=features/auth/uselogout.ts
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 }
}

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 _layout files. 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.

tsx filename=app/_layout.tsx
// 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

  1. The .inline.css extension is detected during the build — Vite processes the CSS normally
  2. The plugin tracks which CSS files should be inlined
  3. At HTML generation time, inline CSS is read from the build output and inlined as <style> tags
  4. 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 — inlineLayoutCSS takes 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:

tsx
import './styles.inline.css' // works out of the box

Comparison

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.

tsx filename=app/_layout.tsx
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 to light or dark, 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:

tsx filename=features/theme/themetogglebutton.tsx
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.

ts filename=vite.config.ts
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

ts
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.

ts
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.

tsx filename=src/entry.tsx
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:

bash
plutil -p ios/MyApp/Info.plist | grep -A 4 CFBundleURLTypes

Test a deep link against a running simulator:

bash
xcrun simctl openurl booted myapp://settings/profile

For 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:

bash
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.


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:

tsx filename=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.

bash
# Build for production
npx one build

# Start the production server
npx one serve

The build output is in the dist directory:

  • dist/client - Static assets and client-side bundles
  • dist/server - Server-side rendering bundles
  • dist/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.

tsx filename=vite.config.ts
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      web: {
        deploy: 'vercel'
      }
    })
  ]
}

Project Setup

  1. Add vercel.json to your project root:
json filename=vercel.json
{
  "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.

  1. Install the Vercel CLI (optional, for manual deploys):
bash
npm install vercel

Git Push Deploys

The easiest way to deploy is connecting your Git repository to Vercel:

  1. Go to vercel.com/new
  2. Import your Git repository
  3. Vercel auto-detects settings from vercel.json
  4. 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:

bash
# Build for Vercel
npx one build

# Deploy using Vercel CLI
vercel deploy --prebuilt

The --prebuilt flag tells Vercel to use the pre-generated output in .vercel/output instead of running its own build process.

For production deploys:

bash
vercel deploy --prebuilt --prod

Testing Production Build Locally

You can do a basic sanity check of your production build locally:

bash
# Build the app
npx one build

# Serve the production build
npx one serve

Note: 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:

bash
# Deploy a preview (not production)
vercel deploy --prebuilt

Every 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.tsx files)
    • API routes (app/api/**)
    • Loaders (data fetching functions)

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:

bash
# Required for SSR and API routes
ONE_SERVER_URL=https://your-app.vercel.app

For local development, add to .env.local:

bash filename=.env.local
ONE_SERVER_URL=http://localhost:8081

Vercel Project Settings

When using vercel.json, settings are auto-configured. If you need to configure manually in the Vercel dashboard:

  1. Framework Preset: Other
  2. Build Command: npx one build
  3. Output Directory: .vercel/output
  4. Install Command: npm install (or bun install, pnpm install)

Static-Only Sites

If your site only uses SSG routes (+ssg.tsx) without SSR, loaders, or API routes, you can simplify:

tsx filename=vite.config.ts
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
bash
npx one build
# Upload dist/client to your static host

Cloudflare Workers

One supports deploying to Cloudflare Workers with full SSR, API routes, and edge performance.

tsx filename=vite.config.ts
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      web: {
        deploy: 'cloudflare'
      }
    })
  ]
}

Build and Deploy

bash
# Build for Cloudflare
npx one build

# Deploy using Wrangler
wrangler deploy --config dist/worker/wrangler.json

Continuous 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 worker
  • CLOUDFLARE_ACCOUNT_ID: the account ID the worker deploys to

A GitHub Actions job then only needs to build and deploy:

yaml
- 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 point
  • dist/worker/wrangler.json - Wrangler configuration for the generated worker
  • dist/client - Static assets served from Cloudflare's edge
  • dist/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.

json
{
  "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:

tsx filename=app/api/track+api.ts
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_compat compatibility 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:

json
{
  "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.

tsx
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.

tsx
<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.

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

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

tsx
<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:

tsx
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.


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:

bash
npm install sharp

If 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:

tsx
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:

tsx
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:

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

tsx
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:

tsx
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:

tsx
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:

yaml
---
title: My Post
image: /images/post-hero.jpg
---

The frontmatter will include imageMeta with dimensions and blur placeholder:

tsx
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:

tsx
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:

tsx
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:

tsx
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 in
vite.config.ts, and one run:ios launches it on a simulator. Apps that keep
Expo prebuild follow Coming from Expo.

**Prerequisites**: To build and run the iOS app, you'll need a Mac with Xcode installed. To upload the app to the App Store or TestFlight, you'll additionally need a paid Apple Developer account. If you are starting with an existing React Native project without using one of our templates, please follow the instructions in the [Project Setup](#project-setup) section to configure your project first.

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:

json
{
  "scripts": {
    "prebuild:native": "one prebuild"
  }
 }

Then run:

bash
yarn prebuild:native

This 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:

bash
one run:ios

This will build and launch the app on your default iOS simulator. For Android, use one run:android.

Make sure the One dev server is running (`one dev`) before launching the native app so it can connect to the bundler.

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:

bash
open ios/*.xcworkspace

Once 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:

bash
open ios/*.xcworkspace

Once 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.
You need a paid [Apple Developer Program](https://developer.apple.com/programs/) account to upload apps to the App Store or TestFlight. If you're not signed into your Apple Developer account in Xcode, select "Add an Account…" from the dropdown to log in. If you encounter any other problems, try to follow the instructions that Xcode give you to fix them.

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.

You can also open the Organizer window by clicking "Window" → "Organizer" on the menu bar.

Project Setup

We've set this up in most project templates, so you can skip this section if you've created your project with `npx one`.

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:

js
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.

If you are using React Native 0.74.x or below, we rely on a patch to the `@react-native-community/cli` package to make this work. For the patch to apply, you'll need to start the dev server at least once.

One Prebuild

Configure the native application in vite.config.ts, then generate the React Native community projects with one prebuild:

ts
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 plus
LSSupportsOpeningDocumentsInPlace 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.

The rest of native.app covers appearance, links, and store settings:

  • orientation: portrait, landscape or default (all four). Unset keeps a
    portrait-only phone.
  • userInterfaceStyle: light or dark locks the appearance; automatic,
    like unset, follows the system.
  • splash.resizeMode: cover fills the iOS launch screen with untrimmed
    artwork; contain (the default) centers it at width.
  • splash.dark: backgroundColor, and optionally source and
    backgroundImage, 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), call One.LaunchScreen.preventAutoHide() while
    modules evaluate and One.LaunchScreen.hide({ fade: true }) when ready.
  • notifications.apsEnvironment: production for a build signed for
    TestFlight or the App Store; push uses development otherwise.
  • ios.accentColor: { light, dark } hex colors for the app's tint (tab
    selection, text cursor, system controls), read in JS as
    PlatformColor('AccentColor').
  • fonts: project-relative .ttf or .otf files bundled into the app. iOS
    lists them in UIAppFonts; Android loads them from assets/fonts. Use the
    font's own family name in styles.
  • android.adaptiveIcon: foreground, then background (an image) or
    backgroundColor, and an optional monochrome layer for Android 13 themed
    icons. The foreground is a 108 dp square with the artwork inside the central
    66 dp.
  • ios.associatedDomains (such as applinks:example.com) and
    android.appLinks ({ host, pathPrefix }, verified with autoVerify) route
    https links into the app.
  • ios.usesAppleSignIn adds the Sign in with Apple entitlement.
  • android.permissions adds manifest permissions (a bare name means
    android.permission.<name>); android.blockedPermissions removes ones a
    library merges in.
  • android.targetSdk and android.compileSdk set the Gradle SDK levels.
  • ios.googleServicesFile and android.googleServicesFile bundle a Firebase
    GoogleService-Info.plist and google-services.json; Android also gets the
    google-services Gradle plugin. For Google Sign-In, add the plist's
    REVERSED_CLIENT_ID to scheme.
  • android.minify runs R8 on release builds, android.shrinkResources (which
    needs minify) drops unused resources, and android.proguardRules appends
    keep rules to proguard-rules.pro.
  • ios.infoPlist and ios.entitlements add keys native.app does not model,
    such as NSUserTrackingUsageDescription. 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.

ts
ios: {
  bundleId: 'com.example.myapp',
  deploymentTarget: '17.0',
  widgets: {
    appGroup: 'group.com.example.myapp',
    kind: 'MyAppStatus',
    displayName: 'My status',
    description: 'Shows the latest status.',
  },
}
ts
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.

tsx
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, and
expandedBottom for the expanded Dynamic Island. The compact slots and
minimal 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. The
write 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

`one prebuild` applies this configuration to generated projects. Follow the manual step only for an existing native project that One did not generate.

In the "Bundle React Native code and images" phase, make the following change in the shell script:

bash
# ⬇️ 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`

  1. Make sure there's a react-native.config.cjs in your project which contains something like this:

    js
    module.exports = require('one/react-native-config')
  2. Confirm native.app.ios.bundleId is set in vite.config.ts.

  3. Delete the generated ios directory and run one prebuild again.


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:

  1. First visitor requests a page → SSR renders it → CDN caches the result
  2. Subsequent visitors get the cached version instantly
  3. After the cache expires, the next visitor still gets the cached version while the CDN fetches a fresh one in the background
  4. 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:

tsx filename=app/blog/[slug]+ssr.tsx
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)

tsx
headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400')
  • public - Allow CDN caching
  • s-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:

tsx
headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=604800')

Short cache (5 minutes)

For frequently updated content:

tsx
headers.set('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=3600')

No caching (dynamic)

For user-specific or real-time content:

tsx
headers.set('Cache-Control', 'private, no-store')

Conditional Caching

Cache public pages, skip for authenticated users:

tsx filename=app/dashboard+ssr.tsx
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:

tsx filename=app/api/posts+api.ts
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

tsx filename=app/blog/[slug]+ssr.tsx
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:

tsx filename=app/dashboard+ssr.tsx
import { redirect, useLoader } from 'one'
import type { LoaderProps } from 'one'

export async function loader({ request }: LoaderProps) {
  const session = await getSession(request)

  if (!session) {
    throw redirect('/login')
  }

  return { user: session.user }
}

export default function Dashboard() {
  const { user } = useLoader(loader)
  return <Text>Welcome, {user.name}</Text>
}

Both throw redirect() and return redirect() work. throw is often cleaner since it stops execution immediately.

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:

  1. The server detects the redirect response from the loader
  2. 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 }
  3. The client receives this module and detects the redirect signal before navigating or rendering anything
  4. The client navigates to the redirect target using a REPLACE action

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:

  1. The native app fetches the loader JavaScript from the server
  2. The server runs the loader, detects the redirect, and transforms it into a module with redirect metadata
  3. The native client detects the __oneRedirect signal after evaluating the loader
  4. The router performs a replace navigation 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:

tsx filename=features/auth/server.ts
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
}
tsx filename=app/dashboard+ssr.tsx
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>
}
tsx filename=app/settings+ssr.tsx
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:

tsx
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.


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:

text
---
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:

bash
yarn add @vxrn/mdx mdx-bundler

We set up our MDX components (using Tamagui in this example):

tsx filename=features/mdxcomponents.tsx
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:

tsx filename=app/docs/[slug].tsx
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:

ts filename=vite.config.ts
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:

  1. It sets noExternal: true in the SSR options, which tells Vite to bundle all dependencies for server-side rendering.
  2. It explicitly marks @vxrn/mdx as 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:

bash
yarn add one

Step 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:

tsx filename=vite.config.ts
import type { UserConfig } from 'vite'
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      web: {
        defaultRenderMode: 'spa'
      }
    }),
  ],
} satisfies UserConfig

This 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:

json filename=package.json
{
  "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:

tsx filename=app/index.tsx
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.html
  • src/index.js
  • src/reportWebVitals.js
  • src/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:

bash
yarn start

Visit 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:

  1. Explore One's features: Check out the One documentation to learn about its powerful features like SSR, file-based routing, and more.

  2. Optimize for production: One provides excellent production optimizations out of the box. Run yarn build to create an optimized production build.

  3. 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.

  4. 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:

bash
npm install @vercel/og

Basic API Route

Create an API route that generates images dynamically:

tsx filename=app/api/og+api.tsx
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:

tsx filename=app/blog/[slug].tsx
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:

tsx filename=app/api/og+api.tsx
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:

tsx filename=app/api/og+api.tsx
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:

tsx filename=features/og.ts
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:

tsx
import { ogUrl } from '~/features/og'

const imageUrl = ogUrl({
  title: 'My Blog Post',
  type: 'blog',
  author: 'Jane Doe',
})

Caching

Add cache headers for better performance:

tsx filename=app/api/og+api.tsx
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:

text
http://localhost:8081/api/og?title=Test+Title

Use 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:

ts filename=vite.config.ts
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>/:

bash
one updates publish --platform ios --out ./ota-out
one updates publish --platform android --out ./ota-out-android

The 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:

bash
bun add @hot-updater/react-native react-native-mmkv
bun add -D hot-updater @hot-updater/bare @hot-updater/supabase
npx hot-updater init

Wrap your app:

tsx
import { HotUpdater } from '@hot-updater/react-native'

export default HotUpdater.wrap({
  source: process.env.ONE_PUBLIC_HOT_UPDATER_URL,
})(App)

Publish:

bash
npx hot-updater deploy -p ios -c production
npx hot-updater deploy -p android -c production

See 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.app fields 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:

Metro mode runs no Babel, so `babel.config.cjs` has no effect on the bundle. To replace a Babel plugin, use `native.bundlerOptions.nativeTransformModules` — see [Metro mode](/docs/metro-mode#no-babel).
bash
npx one metro-eject

This 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:

js
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

tsx filename=vite.config.ts
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:preloadError event 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

tsx filename=vite.config.ts
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

  1. At build time, One emits a version.json file containing the build's cache key
  2. The client polls version.json every 2 minutes with cache-busting headers
  3. When the polled version doesn't match the running bundle's version, the app is marked as stale
  4. On the next client-side navigation, instead of doing an SPA transition, One does a full window.location.href navigation 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:

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

ts
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 for React.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.
tsx
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:

tsx filename=vite.config.ts
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:

text
cache-control: public, s-maxage=60, stale-while-revalidate=120

This 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-cache since 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:

  1. The chunk request 404s
  2. Skew protection detects the failure via vite:preloadError
  3. The page auto-reloads, fetching fresh HTML from the origin
  4. 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:

tsx
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.

Try concrete Tamagui examples by running `npx one`.

We're going to support SSR-safe light and dark mode with the @vxrn/color-scheme helpers that one re-exports:

text
npm add tamagui @tamagui/config @tamagui/vite-plugin

Be sure to check out the Dark mode guide for more detail there.

Add your config/tamagui.config.ts:

tsx filename=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 config

The 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:

tsx filename=app/_layout.tsx
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.

tsx filename=vite.config.ts
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 UserConfig

Optimizing 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:

tsx filename=vite.config.ts
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 UserConfig

And then changing our root layout to add disableInjectCSS, and instead importing the CSS at the top:

tsx filename=app/_layout.tsx
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.

tsx
import { getURL } from 'one'

const url = getURL()
// Dev: "http://127.0.0.1:8081"
// Prod: Your ONE_SERVER_URL value

For 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.

tsx
import { href } from 'one'

const postPageLink = href(`/post/hello-world`) // will type error if invalid Href

This 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.

tsx
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.

tsx
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.

tsx filename=app/dashboard+ssr.tsx
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():

  1. The server detects the redirect response
  2. 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
  3. The client detects the redirect signal before navigating or rendering anything
  4. 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.


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:

tsx filename=app/index.tsx
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.

tsx
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:

tsx filename=app/blog/[slug]+ssr.tsx
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 hour
  • stale-while-revalidate=86400 - Serve stale content while revalidating in background (up to 1 day)
  • max-age=0, must-revalidate - Always revalidate with origin
  • private, 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:

tsx filename=app/auth/login+api.ts
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

tsx filename=app/_middleware.ts
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:


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.

tsx filename=app/blog/[slug].tsx
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.readFile or 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

tsx
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

tsx
// 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

tsx
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>
  )
}

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

tsx
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:

tsx
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 params
  • nextLocation - Where the user is trying to navigate
  • historyAction - 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.

tsx
if (blocker.state === 'unblocked') {
  // Normal state, navigation is allowed
}

`'blocked'`

Navigation has been blocked and is waiting for user decision.

tsx
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.

tsx
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

tsx
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'
}) => boolean

Example: Form with Confirmation Dialog

text
/* Detailed source-code truncated for AI context efficiency. */

Best Practices

  1. Reset dirty state on save - Clear the blocking condition when the user saves their work
  2. Provide clear messaging - Tell users what will be lost if they navigate away
  3. Make actions obvious - Clearly label the "stay" and "leave" options
  4. 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:

tsx
// 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.


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

tsx
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.

tsx
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.

tsx
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.

tsx
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:

tsx
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:

tsx
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

tsx
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:

tsx
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[]
}

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:

tsx
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:

tsx
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 loader

Return Values

When called with a loader

tsx
const { data, refetch, state } = useLoaderState(loader)
  • data: The data returned by the loader (same as useLoader)
  • refetch: A function to manually trigger a fresh loader fetch
  • state: Current loading state - either 'idle' or 'loading'

When called without a loader

tsx
const { refetch, state } = useLoaderState()
  • refetch: A function to manually trigger a fresh loader fetch
  • state: 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:

  1. When refetch() is called from any component, the loader cache for that route is cleared
  2. All components subscribed to that route are notified and re-render
  3. The loader is executed again with fresh data
  4. 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

tsx
function PullToRefreshWrapper({ children }) {
  const { refetch, state } = useLoaderState()

  return (
    <PullToRefresh onRefresh={refetch} refreshing={state === 'loading'}>
      {children}
    </PullToRefresh>
  )
}

Polling for Live Data

tsx
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

tsx
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

tsx
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:

tsx
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 loader

Migration from useLoader

Migrating from useLoader to useLoaderState is straightforward:

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

tsx
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:

ts
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.


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:

tsx
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:

tsx
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:

tsx filename=app/docs/_layout.tsx
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:

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

useMatch

Find a specific route's match by its route ID:

tsx
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):

tsx
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

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 useRouter for most navigation needs. It provides a simpler, strongly-typed API that's designed specifically for One. Use useNavigation only when you need React Navigation-specific features.

Usage

tsx
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:

tsx
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:

tsx
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:

tsx
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):

tsx
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:

tsx filename=app/(tabs)/home/details.tsx
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>
  )
}

Access the current navigation state:

tsx
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 />
}

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:

tsx
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' })
}

useRootNavigationState

Returns the root navigation state object from the NavigationContainer. This gives you access to the top-level navigation tree.

tsx
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.


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

tsx
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

The best way to get fully typed params is with the createRoute helper:

tsx
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:

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

tsx
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:

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

tsx
// 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.


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.

tsx
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

tsx
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

tsx
function Layout({ children }) {
  const pathname = usePathname()
  const showSidebar = !pathname.startsWith('/auth')

  return (
    <div>
      {showSidebar && <Sidebar />}
      {children}
    </div>
  )
}

Analytics

tsx
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.


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:

tsx
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:

tsx
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.

useNavigationContainerRef

Returns a ref to the NavigationContainer component. This allows direct access to the React Navigation container.

tsx
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.

tsx
import { useUnstableGlobalHref } from 'one'

function Component() {
  const href = useUnstableGlobalHref()
  // e.g., '/docs/routing?tab=settings'
}
This hook is marked unstable and subject to change. Use `usePathname()` and `useSearchParams()` for stable alternatives.

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:

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

tsx
// Create a scroll group for a specific path prefix
useScrollGroup('/products')

Programmatic Scroll Groups

You can also specify a scroll group when navigating programmatically:

tsx
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

  1. When a layout calls useScrollGroup(), it registers the current path as a scroll group
  2. When navigating, if both the previous and new routes are within the same scroll group, the scroll position is preserved
  3. 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

tsx
function useScrollGroup(groupPath?: string): void

Parameters

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

tsx
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:

tsx
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:

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

tsx
// Updates even when this screen is not focused
const searchParams = useSearchParams({ global: true })
The `global` option cannot change between renders.

Read-Only

The returned URLSearchParams is read-only. Calling set(), append(), or delete() will throw an error:

tsx
const searchParams = useSearchParams()

// ❌ These will throw
searchParams.set('key', 'value')
searchParams.append('key', 'value')
searchParams.delete('key')

To update search params, use the router:

tsx
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:

tsx
// 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 to useActiveParams(). Returns params that update even when the route is unfocused.
  • useLocalSearchParams - Alias to useParams(). Returns focus-aware params.

Prefer the non-aliased names in new code.


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.

tsx
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:

tsx
// 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

tsx
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

tsx
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

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

tsx
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.

tsx
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:

tsx
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:

ts
type ServerHeadInsertionCallback = () => React.ReactElement

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.

tsx
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).

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

tsx
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:

tsx
function visibleChildren(node: SitemapType) {
  return node.children.filter((child) => !child.isInternal && !child.isGenerated)
}

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:

tsx
// 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.


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:

tsx
// 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.

tsx
<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.


Apps Onestack.Dev Data Docs Installation (`apps/onestack.dev/data/docs/installation.mdx`)


title: Installation

Getting started with One is as easy as:

bash
npx one

The 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.

**Using an AI agent?** Install the official One skills so Claude Code, Cursor, Codex, etc. know how to build with One:
bash
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:

json filename=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:

bash
# 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 --exact

This 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.

**Platform Support**: One currently doesn't support Windows or Bun with the `--bun` runtime for development. We're happy to accept contributions that add support for these platforms. For now, please use macOS or Linux with Node.js or other supported package managers.

Vite Config

Next, let's add our ultra-simple vite.config.ts:

tsx filename=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 UserConfig

TSConfig

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:

json
{
  "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:

  1. Consistent page structure
  2. Global styles or themes
  3. Persistent navigation
  4. 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:

tsx filename=app/_layout.tsx
import { Slot } from 'one'

export default function Layout() {
  return <Slot />
}

And then create a simple index page app/index.tsx:

tsx filename=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. 🎉

bash
bun install && bun run dev

Running 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:

bash
bun run prebuild:native
bun run ios
# or
bun run android

One'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:

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 one npm 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.

New apps should stay on One's default bundler. Metro mode exists for projects that still depend on Metro-specific resolver or transformer settings while they move over.

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:

sh
bun add -d @vxrn/vite-plugin-metro babel-preset-expo

One reports an installation error naming the missing package when Metro starts without them. The default Rolldown path loads neither.

tsx filename=vite.config.ts
import type { UserConfig } from 'vite'
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      native: {
        // Enable Metro bundler for native builds
        bundler: 'metro',
      },
    }),
  ],
} satisfies UserConfig

Limitations

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.

Metro mode intentionally supports only a subset of Vite configurations and features. This approach helps avoid implementation differences between Vite and Metro that could cause compatibility issues with React Native packages.

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.env Built-in Constants : supported but may not work as expected for web specific variables.
  • import.meta.env Env Variables
  • tsconfig-paths : As one automatically configures vite-tsconfig-paths to support path mappings defined in tsconfig.json for your project, the same applies to Metro mode. However, if you are using the vite-tsconfig-paths plugin 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.

We recommend using the standard Vite way for configuring if possible, as it provides a more consistent experience across web and native builds, and will be more future-proof to switch between bundlers.

Like ordinary React Native apps, you can still configure Metro using metro.config.js for more advanced settings, such as resolver or watcher options:

js filename=metro.config.js
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 absolute ctx.filename.
  • Remove it from native.bundlerOptions.babelConfigOverrides.
  • Set native.bundlerOptions.nativeTransforms: false to use Babel, or ONE_METRO_NATIVE_TRANSFORMS=0 for a single run. Install @react-native/babel-preset and 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 run one metro-eject to create a Babel config that delegates to one/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:

js filename=scripts/my-transform.cjs
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))
}
tsx filename=vite.config.ts
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:

tsx filename=vite.config.ts
import type { UserConfig } from 'vite'
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      native: {
        bundler: 'metro',
        bundlerOptions: {
          startup: 'lazy',
        },
      },
    }),
  ],
} satisfies UserConfig

Or via environment variable:

sh
ONE_METRO_LAZY=1 one dev

With 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:

ts
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():

tsx
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 exported
useNetworkState 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 from
One.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.

Family Components and reference
Layout Host, HStack, VStack, ZStack, Spacer, LabeledContent, grids, and lazy stacks
Groups Group, ControlGroup, DisclosureGroup, GroupBox, ViewThatFits, and GlassEffectContainer
Lists List, ScrollView, Form, Section, Overlay, ViewSlot, and SwipeActions
Actions Button, Toggle, Slider, Stepper, PasteButton, ShareLink, SignInWithAppleButton, EditButton, RenameButton, LocationButton, and ShortcutsLink
Text TextField, SecureField, and TextEditor
Selection Picker, DatePicker, and ColorPicker
Presentation Sheet, FullScreenCover, Popover, Alert, and ConfirmationDialog
TabView TabView, Tab, TabSection, TabViewSlot, TabViewBottomAccessory, Pager, and Page
Navigation NavigationStack, Toolbar, ToolbarItem, ToolbarItemGroup, and ToolbarSpacer
Indicators ProgressView and Gauge
Content Text, Label, Image, AsyncImage, VideoPlayer, LivePhotoView, PhotosPicker, WebView, Map, QuickLook, FileImporter, and ContentUnavailableView
Charts Chart, drawn with Swift Charts
Store ProductView, StoreView, and SubscriptionStoreView
Shapes Circle, Capsule, Rectangle, RoundedRectangle, Ellipse, UnevenRoundedRectangle, and ConcentricRectangle

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:

tsx
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:

tsx
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 of
One.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.

tsx
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 arbitrary
One.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 or
composeStyle 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:

bash
npx one

For 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:

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:

js
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:

bash
npm install --save-dev @react-native-community/[email protected]

Then generate the projects from the app directory:

bash
one prebuild

One 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:

bash
one dev

Build and launch the generated app in another:

bash
one run:ios

Or 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:

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.

swift
@MainActor final class Clock: PeachModule {
  init() {}

  private var tick: ((Double) -> Void)?
  func onTick(_ callback: @escaping (Double) -> Void) {
    tick = callback
  }
}
kotlin
object Clock : OneModule {
    private var tick: ((Double) -> Unit)? = null
    fun onTick(callback: (Double) -> Unit) {
        tick = callback
    }
}
tsx
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 as
peach_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 or
data 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 a
Date, and Data or ByteArray crosses as base64 revived to a
Uint8Array.

swift
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 }
}
kotlin
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
}
tsx
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.

tsx
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 a
Package.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 the
Package.swift; Swift packages deeper inside it belong to that package.

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

swift
// 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()
  }
}
ts
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:

swift
// 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))
  }
}
tsx
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:

swift
@main struct Badge: PeachPackage {
  func view(props: JSON) -> some View {
    Text(props["title"]?.stringValue ?? "")
  }
}
tsx
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's
workspaces 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.

kotlin
// 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)
}
tsx
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 or
Device.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:

text
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 = { ... }  // web

Screens 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):

bash
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 build

To build for iOS for example, you would run:

bash
npx one build --platform ios

Building 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:

text
ONE_SERVER_URL=https://onestack.dev

We 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:

tsx filename=vite.config.ts
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:

tsx filename=vite.config.ts
import { one } from 'one/vite'

export default {
  plugins: [
    one({
      build: {
        concurrency: 4
      }
    })
  ]
}

ONE_BUILD_CONCURRENCY overrides the config value for one-off builds:

bash
ONE_BUILD_CONCURRENCY=2 npx one build

You can also disable worker threads entirely through config or an environment variable:

tsx filename=vite.config.ts
one({
  build: {
    workers: false
  }
})
bash
ONE_BUILD_WORKERS=0 npx one build

Security 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:

tsx filename=vite.config.ts
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):

bash
--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 config

You 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:

  1. Run one prebuild to generate the native Xcode/Android project
  2. Run one run:ios or one run:android to 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):

bash
--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 workers

You 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.

bash
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 dev instead)
  • 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:

tsx
import { serve } from 'one/serve'

await serve()

Which takes options:

tsx
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.

tsx filename=vite.config.ts
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.

text
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 content

This 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:

text
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 default

Render mode hierarchy:

  1. File suffix (highest priority) - page+spa.tsx
  2. Parent folder suffix - dashboard+ssr/page.tsx
  3. Vite configuration (lowest priority) - defaultRenderMode: 'ssg'

Nested folders:
Inner folder suffixes override outer folder suffixes:

text
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/analytics becomes /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:

text
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.

tsx filename=app/api/hello+api.ts
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

tsx filename=app/api/hello+api.ts
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:

tsx filename=app/api/users+api.ts
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:

tsx
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:

tsx filename=app/api/users/[id]+api.ts
export function GET(request: Request, { params }: { params: { id: string } }) {
  return Response.json({ id: params.id })
}

Rest parameters work the same way:

tsx filename=app/api/files/[...path]+api.ts
export function GET(request: Request, { params }: { params: { path: string[] } }) {
  const filePath = params.path.join('/')
  return Response.json({ path: filePath })
}

Request Handling

Reading the Body

tsx
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

tsx
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

tsx
export function GET(request: Request) {
  const auth = request.headers.get('Authorization')

  return Response.json({ authenticated: !!auth })
}

Response Handling

Custom Status Codes

tsx
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

tsx
export function GET(request: Request) {
  return new Response(JSON.stringify({ data: [] }), {
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'public, max-age=3600',
    },
  })
}

Redirects

tsx
import { redirect } from 'one'

export function GET(request: Request, { params }: { params: { id: string } }) {
  return redirect(`/api/v2/users/${params.id}`)
}

Error Handling

tsx
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:

tsx
import type { Endpoint } from 'one'

export const GET: Endpoint = (request) => {
  return Response.json({ hello: 'world' })
}

For typed params, use createAPIRoute:

tsx
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.

tsx filename=app/api/track+api.ts
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:

ts
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.

Note that `_layout` routes are special and do not support these exports unless specified.

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.

tsx filename=app/(app)/_layout.tsx
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:

tsx filename=app/dashboard.tsx
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:

tsx filename=app/blog/[month]/[year]/[slug]+ssg.tsx
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:

tsx
;[
  {
    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.

tsx filename=app/blog/[slug].tsx
// 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:

tsx filename=app/admin/dashboard.tsx
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:

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:

tsx filename=app/_layout.tsx
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:

tsx filename=app/docs/_layout.tsx
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:

tsx filename=app/docs/_layout.tsx
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> or useRouter().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:

tsx filename=app/_layout.tsx
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:

text
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.

tsx filename=app/_layout+ssg.tsx
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:

tsx
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:

tsx filename=app/_layout.tsx
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:

tsx filename=(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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx filename=app/docs/_layout.tsx
import { useLoader, Slot } from 'one'

export async function loader() {
  return {
    navItems: await fetchDocsNav()
  }
}

export default function DocsLayout() {
  const { navItems } = useLoader(loader)

  return (
    <div>
      <Sidebar items={navItems} />
      <Slot />
    </div>
  )
}

See Layout Loaders for more details.

Loader arguments

Loaders receive a single argument object:

params

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

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

path

The path key is the fully resolved pathname:

tsx filename=app/user/[id].tsx
export async function loader({ path }) {
  // if the route is /user/123 then path is "/user/123"
}

request

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

Accepted Return Types

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

You may also return a Response:

tsx
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:

tsx
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.

tsx filename=app/dashboard+ssr.tsx
import { redirect, useLoader } from 'one'
import type { LoaderProps } from 'one'

export async function loader({ request }: LoaderProps) {
  const session = await getSession(request)

  if (!session) {
    throw redirect('/login')
  }

  return { user: session.user }
}

export default function Dashboard() {
  const { user } = useLoader(loader)
  return <Text>Welcome, {user.name}</Text>
}

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

How Loader Redirects Work

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

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

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

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

Redirects at Build Time

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

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

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

Setting Response Headers

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

Cache Headers (ISR)

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

tsx
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:

tsx
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):

tsx
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:

tsx
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:

tsx filename=app/cards/[id]+ssr.tsx
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):

tsx
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:

tsx
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, loaderCache improved 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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

  1. app/_middleware.ts first
  2. app/blog/_middleware.ts second
  3. Then the route handler

Basic Usage

tsx filename=app/_middleware.ts
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:

tsx
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:

tsx filename=app/_middleware.ts
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:

tsx filename=app/api/_middleware.ts
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()
})
The middleware above runs identically in `one dev` and `one serve` — the same `OPTIONS` branch answers preflight requests in both. One configures Vite's dev server with `server.cors.preflightContinue: true` so that Vite's built-in cors middleware doesn't intercept preflight before your middleware sees it. If you override `server.cors` in your `vite.config.ts`, keep `preflightContinue: true`. See [API Routes → Dev and prod parity](/docs/routing-api-routes#dev-and-prod-parity) for details.

Or modify the response directly:

tsx filename=app/_middleware.ts
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:

tsx filename=app/_middleware.ts
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:

tsx filename=app/_middleware.ts
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:

tsx filename=app/api/_middleware.ts
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:

tsx filename=app/_middleware.ts
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:

tsx filename=app/_middleware.ts
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:

text
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 middleware

Each 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.


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"' }
] },
]}
/>

You can use the Link component as follows:

tsx filename=app/index.tsx
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:

tsx
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):

typescript
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:

tsx filename=app/docs/[slug].tsx
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:

tsx filename=app/docs/[slug].tsx
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:

tsx filename=vite.config.ts
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:
tsx
import type { RouteType } from 'one'
type Route = RouteType<'/your/[route]'>
  • 'runtime': Auto-inserts runtime helpers:
tsx
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:

bash
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 helpers

Note: 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

tsx filename=app/blog/[category]/[year]/[slug].tsx
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

tsx filename=app/files/[...path].tsx
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

  1. One scans your app directory for route files
  2. Extracts dynamic segments like [slug] and [...rest]
  3. Generates TypeScript types in app/routes.d.ts
  4. 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:

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

tsx filename=app/index.tsx
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:

tsx filename=app/index.tsx
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:

tsx
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:

tsx
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:

bash
yarn one generate-routes

If your app directory is not named app, you can specify it:

bash
yarn one generate-routes --appDir=src

Apps 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 ---