GuidesReact Native

Expo App with Hono, tRPC, and Better Auth

Create an Expo Router app with a Hono API, tRPC, Better Auth email sign-in, Drizzle, and SQLite in one Turborepo workspace using Better Fullstack.

Updated October 6, 2026exporeact-nativehonobetter-auth

Use this stack when a native app needs its own backend from the first commit: sign-in, typed API calls, and a database in the same repository.

npm create better-fullstack@latest my-expo-app -- \  --ecosystem typescript \  --frontend native-uniwind \  --backend hono \  --runtime bun \  --database sqlite \  --orm drizzle \  --db-setup none \  --auth better-auth \  --api trpc \  --mobile-navigation expo-router \  --mobile-ui uniwind

The command uses --ecosystem typescript on purpose. The react-native ecosystem generates the mobile app only and does not add a backend, database, or auth; a native frontend inside the TypeScript ecosystem does.

What this creates

  • An Expo app in apps/native with Expo Router, a drawer and tab layout, Uniwind styling, and HeroUI Native components.
  • Email and password sign-in and sign-up components backed by the Better Auth Expo client, which stores the session with expo-secure-store.
  • A Hono server in apps/server that mounts Better Auth at /api/auth/* and tRPC at /trpc/*.
  • A tRPC router with a public healthCheck, a protected privateData query, and protected tasks procedures.
  • Drizzle on SQLite through libSQL, with auth tables and a task table.
  • A Turborepo workspace that runs the app and the server together.

Generated shape

txt
my-expo-app/
├── package.json
├── turbo.json
├── apps/
│   ├── native/
│   │   ├── app/
│   │   │   ├── _layout.tsx
│   │   │   └── (drawer)/
│   │   ├── components/
│   │   │   ├── sign-in.tsx
│   │   │   └── sign-up.tsx
│   │   ├── lib/auth-client.ts
│   │   ├── utils/trpc.ts
│   │   └── .env
│   └── server/
│       ├── src/index.ts
│       └── .env
└── packages/
    ├── api/
    ├── auth/
    ├── config/
    ├── db/
    └── env/

Prerequisites

  • Node.js and npm for the workspace, and Bun for the server, which runs with bun run --hot.
  • Expo tooling, plus an iOS simulator, Android emulator, or physical device.

Run and verify

bash
cd my-expo-app
npm install
npm run db:push
npm run dev

db:push applies the Drizzle schema to the SQLite file named in apps/server/.env (DATABASE_URL=file:../../local.db). npm run dev starts both apps through Turborepo; npm run dev:server and npm run dev:native start one at a time.

The home screen calls trpc.healthCheck and shows a status chip. Without a session it renders the sign-in and sign-up forms. It also queries the protected privateData procedure, which returns a message only for a signed-in user.

The root npm run test script runs Vitest in the server and API packages with --passWithNoTests. No test files are generated.

How the session reaches the API

On native platforms the tRPC client copies the Better Auth cookie into each request:

ts
headers() {
  if (Platform.OS === "web") {
    return {};
  }
  const headers = new Map<string, string>();
  const cookies = authClient.getCookie();
  if (cookies) {
    headers.set("Cookie", cookies);
  }
  return Object.fromEntries(headers);
},

The server builds the tRPC context from the same headers, and protectedProcedure rejects requests without a session:

ts
export async function createContext({ context }: CreateContextOptions) {
  const session = await auth.api.getSession({
    headers: context.req.raw.headers,
  });
  return { session };
}

The tasks procedures are ready on the server, but no screen calls them yet. They are a good first feature to wire into a tab.

Environment and devices

apps/native/.env sets EXPO_PUBLIC_SERVER_URL=http://localhost:3000. That works in an iOS simulator on the same machine. A physical device or an Android emulator cannot reach the computer's localhost, so set the URL to the machine's LAN address and restart Expo.

apps/server/.env holds a generated BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGIN, and DATABASE_URL. Treat the secret as local-only and generate a new one for each deployed environment.

Compatibility notes

  • Better Auth's server config lists mybetterfullstackapp:// in trustedOrigins, while apps/native/app.json sets the app scheme to the project name (my-expo-app). Add my-expo-app:// to trustedOrigins in packages/auth/src/index.ts so the trusted list includes the app's own scheme.
  • --frontend native-uniwind selects Expo with Uniwind; native-bare and native-unistyles are the other Expo variants.

Deployment notes

Mobile and server ship separately. Build the app with your Expo build profiles, and set EXPO_PUBLIC_SERVER_URL to the deployed API at build time. Deploy apps/server to a host that runs Bun, and point DATABASE_URL at a hosted libSQL or Turso database. Turso also needs an auth token, which this scaffold does not read because the command selects --db-setup none. Add DATABASE_AUTH_TOKEN to the server schema in packages/env/src/server.ts, pass authToken: env.DATABASE_AUTH_TOKEN to createClient in packages/db/src/index.ts, add authToken: process.env.DATABASE_AUTH_TOKEN to dbCredentials in packages/db/drizzle.config.ts, and set the token in the server environment. Do not put server secrets in the mobile bundle; anything prefixed EXPO_PUBLIC_ ships to devices.

Troubleshooting

SymptomCheck
Status chip shows OFFLINE on a deviceEXPO_PUBLIC_SERVER_URL must use an address the device can reach, not localhost.
Sign-up fails with a missing table errorRun npm run db:push before signing up.
privateData returns UNAUTHORIZEDSign in first; the query is a protectedProcedure.
Auth requests are rejected for their originAdd the app scheme from app.json to trustedOrigins.
No apps/server folder was generatedUse --ecosystem typescript; the react-native ecosystem does not generate a backend.

Next steps

GitHub Sponsors