On web, <Stack>, <Tabs>, and <Drawer> render headless: just the matched route’s element. No tab bar, no header, no wrapper elements, no styles. Native is unchanged - react-navigation still renders full chrome exactly as before.
import { Tabs } from 'one'
export default function Layout() { return ( <Tabs> <Tabs.Screen name="home" options={{ title: 'Home' }} /> <Tabs.Screen name="feed" options={{ title: 'Feed' }} /> </Tabs> )}On native this renders a bottom tab bar as usual. On web it renders only the focused tab’s screen - no bar. Your site’s own nav is your tab bar.
The first time a navigator renders headless on web, it logs a one-line note in development so the missing chrome doesn’t read as breakage:
[one] <Tabs> renders headless on web (just the matched route). Pass your own layout as a child, or build one with useTabs().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.
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.Set keepMounted on a screen and the navigator’s default web view keeps it alive once you navigate away, hidden via React 19.2’s <Activity mode="hidden"> instead of unmounting, so scroll position and form state survive.
<Stack.Screen name="settings" options={{ keepMounted: true }} />It works the same in all three navigators. In Tabs and Drawer a screen is only kept once it has actually been focused, matching react-navigation’s lazy behavior on native. Custom layouts render whatever they want, so they own this themselves - keepMounted is on every entry from useStack, useTabs, and useDrawer.
Add a component as a child of the navigator. It replaces the headless default on web.
// app/(tabs)/_layout.tsx, works on both platformsimport { 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, Stack.Toolbar) 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, 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.
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:
import { Presentations, Slot } from 'one'import { Modal } from '~/interface/Modal'import { Sheet } from '~/interface/Sheet'
export default function Layout() { return ( <Presentations web={{ sheet: Sheet, modal: Modal }}> <Slot /> </Presentations> )}Sheet and Modal are ordinary components receiving open, onOpenChange, options, screen, and children.
Any layout that declares a sheet or modal screen picks this up automatically, using the same presentation/sheetAllowedDetents vocabulary you’d write for native:
<Stack> <Stack.Screen name="compose" options={{ presentation: 'sheet', sheetAllowedDetents: [0.6, 1] }} /></Stack>Omit sheet or modal and that kind keeps its headless default, so you only need to write the presentations you actually use.
The react-native to react-native-web alias is still there for your own code, so import { View } from 'react-native' keeps working on web. What changed is that One’s own web output no longer contains any of it, so an app that doesn’t import react-native itself never loads it.
One also stopped extracting react-native-web’s generated stylesheet during SSR, since that only mattered when the framework rendered react-native components for you. If your app renders them on web and you want their styles in the server HTML instead of after hydration, do the extraction in your root layout:
import { Slot, useServerHeadInsertion } from 'one'import { AppRegistry } from 'react-native'
export default function Layout() { useServerHeadInsertion(() => { AppRegistry.registerComponent('styles', () => () => null) // @ts-expect-error web-only api return AppRegistry.getApplication('styles', {}).getStyleElement() })
return <Slot />}Longer term the recommendation is to drop react-native-web on web. It’s in maintenance mode, and One gives you the DOM directly. Use Tamagui, React Strict DOM, or plain elements with CSS for shared primitives.
Native is unaffected by any of this. <Stack>, <Tabs>, and <Drawer> still render through react-navigation exactly as before - real headers, a real bottom tab bar, a real drawer. Nothing about your Screen config, screenOptions, or Stack.Header/Stack.Toolbar composition changes.
Edit this page on GitHub.