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.
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 uniwindThe 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/nativewith 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/serverthat mounts Better Auth at/api/auth/*and tRPC at/trpc/*. - A tRPC router with a public
healthCheck, a protectedprivateDataquery, and protectedtasksprocedures. - Drizzle on SQLite through libSQL, with auth tables and a
tasktable. - A Turborepo workspace that runs the app and the server together.
Generated shape
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
cd my-expo-app
npm install
npm run db:push
npm run devdb: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:
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:
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://intrustedOrigins, whileapps/native/app.jsonsets the app scheme to the project name (my-expo-app). Addmy-expo-app://totrustedOriginsinpackages/auth/src/index.tsso the trusted list includes the app's own scheme. --frontend native-uniwindselects Expo with Uniwind;native-bareandnative-unistylesare 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
| Symptom | Check |
|---|---|
Status chip shows OFFLINE on a device | EXPO_PUBLIC_SERVER_URL must use an address the device can reach, not localhost. |
| Sign-up fails with a missing table error | Run npm run db:push before signing up. |
privateData returns UNAUTHORIZED | Sign in first; the query is a protectedProcedure. |
| Auth requests are rejected for their origin | Add the app scheme from app.json to trustedOrigins. |
No apps/server folder was generated | Use --ecosystem typescript; the react-native ecosystem does not generate a backend. |
Next steps
- Compare with the mobile-only Expo Uniwind Native App.
- Read the React Native ecosystem docs.
- Review the CLI create reference.