A batteries-included, mobile-first React Native monorepo starter — Expo + Turborepo + pnpm, wired with NativeWind v5 (Tailwind v4) and React Native Reusables components that are pre-fixed to render correctly on native (not just web).
The point of rnstack is to skip the days normally lost to monorepo + NativeWind + RNR setup quirks. Clone it, install, run — you get a working app with 30+ UI components, theming, and light/dark mode out of the box.
Status: scaffold a new project in one command with
create-rnstack, or clone this repo directly. Includes tab navigation (Home + Settings), light/dark/system theme (persisted), and a typed API layer (refresh-token auth, TanStack Query).
Expo SDK 56 (New Architecture, RN 0.85) · Expo Router · NativeWind v5 (Tailwind v4) · React Native Reusables · TypeScript 6 · pnpm + Turborepo · Biome · Node ≥ 22.13.
apps/
mobile/ Expo app (Expo Router) — the reference app
packages/
ui/ @repo/ui — React Native Reusables components, theme toggle, cn()
api-client/ @repo/api-client — http() + 401 refresh + pluggable auth + TanStack Query
config/ @repo/config — shared tsconfig base + Biome config
create-rnstack/ the scaffolding CLI (published to npm as create-rnstack)
pnpm create rnstack my-app # or: npx create-rnstack my-appScaffolds a fresh monorepo (pick app names + package manager), installs deps, and prints next
steps. See packages/create-rnstack for flags.
pnpm install
cp .env.example apps/mobile/.env # set EXPO_PUBLIC_API_BASE_URL
pnpm start # turbo run start → expo startThen open the app on a device/emulator (press a for Android, i for iOS, w for web).
This project targets Expo SDK 56. The Expo Go in the app stores may not be updated for SDK 56 yet ("Project is incompatible with this version of Expo Go"). Install the SDK-56 build directly from Expo: https://expo.dev/go?sdkVersion=56&platform=android&device=true, or build a dev client (
eas build --profile development), or run in the browser (w).
pnpm format # biome format --write .
pnpm lint # turbo run lint
pnpm typecheck # turbo run typecheck
pnpm changeset # declare a version bump for any package you changedPackages are versioned independently with Changesets.
For any change that should ship, run pnpm changeset, pick the affected package(s) and bump type,
and commit the generated file. Releasing runs pnpm version (applies bumps + CHANGELOGs) then
pnpm release (changeset publish). Only create-rnstack publishes to npm today; the app and
@repo/* packages are versioned but private.
rnstack is build-tool agnostic — it ships with no EAS / cloud account baked in. Pick the path that fits you.
pnpm start # turbo run start → expo start; press a / i / wRuns in Expo Go (SDK 56 — see the note above) or the browser. This is all most contributors need.
Compile a real binary on your own machine — no account required:
cd apps/mobile
npx expo run:android # builds & installs a debug APK on a device/emulator
npx expo run:ios # macOS + Xcode
# release artifacts via prebuild + native tooling:
npx expo prebuild # generates ios/ & android/ (gitignored)
cd android && ./gradlew assembleRelease # → app/build/outputs/apk/release/*.apkEAS Build compiles on Expo's servers, so you can produce an installable APK/AAB (and iOS builds) with no local native toolchain. rnstack does not configure EAS for you — it's tied to a personal account, so each developer links their own:
npm i -g eas-cli # or: pnpm dlx eas-cli
eas login
cd apps/mobile
eas init # links YOUR Expo project; writes owner + projectId into app.json
eas build:configure # generates an eas.json with build profiles
eas build --platform android --profile preview # prints a download URL / QR for the APKOn the first Android build, answer yes to "Generate a new Android Keystore?" — Expo creates and
stores the signing key for you (no local keytool). Builds run asynchronously; press Ctrl+C after
it queues and re-attach with eas build:list.
⚠️ Monorepo gotcha: run everyeascommand fromapps/mobile/(whereapp.jsonlives), not the repo root — at the root the CLI links the wrong project and writes a stray rooteas.json. Theowner/extra.eas.projectIdthateas initwrites are yours — they belong in your own copy, and are safe to commit in a private app repo (they're public identifiers, not secrets). The starter intentionally ships without them.
Bottom-tab navigation (expo-router) with two starter screens under
app/(tabs)/:
- Home (
index.tsx) — a clean welcome screen to replace with your first feature. - Settings (
settings.tsx) — Appearance (light / dark / system, persisted via AsyncStorage), a Developer section linking the component gallery and data-fetching demo, and About (app version).
The theme preference is owned by @repo/ui/lib/theme-context (ThemeProvider +
useThemePreference), wrapped once in app/_layout.tsx. "System" follows the OS; the choice
survives restarts.
Theme lives in one place: apps/mobile/src/global.css. It has
three parts:
- Token values —
@layer base { :root { --primary: hsl(…); } }plus a@media (prefers-color-scheme: dark)block. Edit these values to re-brand. - Utility registration —
@theme inline { --color-primary: var(--primary); … }tells Tailwind v4 to generatebg-primary/text-primaryetc. - Content sources —
@source "../../../packages/ui/src/**/*.{ts,tsx}"so Tailwind scans the shared UI package (omit it and shared components render unstyled).
Components reference semantic tokens (bg-primary, text-foreground, …) — never literal
colors. Dark mode follows the system scheme; toggle it at runtime with the ThemeToggle
component (Appearance.setColorScheme()).
Native theming rules (each fixes a bug that only shows on device):
- Store tokens as full colors (
hsl(0 0% 9%)), not bare channels — channel tokens + opacity modifiers (bg-primary/90) flicker on theme change on native.- Radius tokens must be concrete rems (
0.5rem), notcalc(var(--radius) - 2px)— nestedcalc(var())collapses to 0 (square corners) on native.
Because RNR components were authored for NativeWind v4 and rnstack runs NativeWind v5 (preview) + Tailwind v4, every component must be verified on a real device. The app ships a gallery for exactly this:
pnpm start # open in Expo Go, then tap "Browse component gallery"Routes live in apps/mobile/src/app/gallery/ — an index grouped by
risk tier (primitives → inputs → overlays) and one screen per component showing its variants/states,
each with a ThemeToggle so you can check light/dark + flicker on the spot.
Components come from the React Native Reusables CLI into packages/ui:
cd packages/ui
npx @react-native-reusables/cli@latest add <name> -y
# or all of them:
npx @react-native-reusables/cli@latest add --all -ycomponents.json aliases route them into @repo/ui. After adding, audit for native gotchas
(no CSS grid on native, Android TextInput clipping, web-only utilities) — see the
rnstack-project skill in .claude/skills/ for the
full checklist.
A typed HTTP client with single-flight 401-refresh-and-retry, wired to TanStack Query —
deliberately auth-agnostic. The client owns transport (base URL + /v1, JSON, error
envelope, refresh on 401); it never owns auth. Auth is injected via an AuthProvider:
// apps/mobile/src/lib/api.ts — the app's configured client
import { env } from "@/lib/env"; // Zod-validated; throws at startup if .env is missing/invalid
export const api = createHttpClient({
baseUrl: env.EXPO_PUBLIC_API_BASE_URL,
auth: createJwtAuthProvider({ refreshUrl: `${env.EXPO_PUBLIC_API_BASE_URL}/v1/auth/refresh` }),
});The default provider stores JWT access/refresh tokens in expo-secure-store. Using Clerk,
Supabase, or Firebase instead? Write a ~15-line AuthProvider that returns your SDK's token —
the client, refresh, and retry plumbing stay identical. Screens call query hooks, never
fetch. ApiProvider wraps the app in app/_layout.tsx.
Requests are cancellable — pass an AbortSignal (api.get("/x", { signal })). Forward the
signal TanStack Query provides so queries auto-cancel on unmount/refetch:
queryFn: ({ signal }) => api.get("/x", { signal }) (see use-example-posts).
The data-demo screen + use-example-posts hook are a removable example pointed at a public API
so a fresh clone shows live data — delete both when you wire your backend.
pnpm-workspace.yaml sets nodeLinker: hoisted. pnpm's default isolated store loads two copies
of react-native into one bundle (a Maximum call stack size exceeded crash at startup);
hoisting guarantees a single native runtime. This is Expo's recommended linker for monorepos.
The core starter, API layer, and scaffolding CLI are in place. Possible follow-ups:
- Auth/login starter screens on top of the API layer
- Pagination / infinite-query helpers in
@repo/api-client - A web (Next.js) app target
ISC © Sanjay Kumar Sah