Skip to content

sanjaysah101/rnstack

Repository files navigation

rnstack

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

Stack

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.

Layout

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)

Quick start (new project)

pnpm create rnstack my-app          # or: npx create-rnstack my-app

Scaffolds a fresh monorepo (pick app names + package manager), installs deps, and prints next steps. See packages/create-rnstack for flags.

Getting started (this repo)

pnpm install
cp .env.example apps/mobile/.env     # set EXPO_PUBLIC_API_BASE_URL
pnpm start                           # turbo run start → expo start

Then open the app on a device/emulator (press a for Android, i for iOS, w for web).

⚠️ Expo Go & SDK 56

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

Quality gate (run before committing)

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 changed

Versioning

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

Running & building

rnstack is build-tool agnostic — it ships with no EAS / cloud account baked in. Pick the path that fits you.

1. Local dev (default — no native toolchain)

pnpm start            # turbo run start → expo start; press a / i / w

Runs in Expo Go (SDK 56 — see the note above) or the browser. This is all most contributors need.

2. Local native build (you have Android Studio / Xcode)

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

3. EAS cloud build (opt-in — no local Android Studio / Xcode)

EAS 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 APK

On 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 every eas command from apps/mobile/ (where app.json lives), not the repo root — at the root the CLI links the wrong project and writes a stray root eas.json. The owner / extra.eas.projectId that eas init writes 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.

Screens & navigation

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.

Theming

Theme lives in one place: apps/mobile/src/global.css. It has three parts:

  1. Token values@layer base { :root { --primary: hsl(…); } } plus a @media (prefers-color-scheme: dark) block. Edit these values to re-brand.
  2. Utility registration@theme inline { --color-primary: var(--primary); … } tells Tailwind v4 to generate bg-primary / text-primary etc.
  3. 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), not calc(var(--radius) - 2px) — nested calc(var()) collapses to 0 (square corners) on native.

Component gallery (test harness)

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.

Adding UI components

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

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

Data fetching & auth (@repo/api-client)

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.

Why pnpm nodeLinker: hoisted?

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.

Roadmap

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

License

ISC © Sanjay Kumar Sah

About

Mobile-first React Native monorepo starter Kit Expo 56 + Turborepo + pnpm, NativeWind v5 and React Native Reusables components pre-fixed to work on native.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors