TL;DR
- A "boilerplate" is not a folder structure. It's auth, env handling, an API client, navigation guards, and CI that someone has to maintain.
- Building your own takes longer than you think, mostly because of the boring 20%: token refresh, env validation, upgrade churn.
- Buying saves weeks but you inherit someone else's opinions and their upgrade schedule.
- Build if the starter is your competitive edge or you ship many apps on one stack. Buy or generate if you need to validate an idea this month.
- Whatever you choose, judge it by the four files below, not the feature list.
The boilerplate you think you're building
Every build-it-yourself plan starts like this:
npx create-expo-app@latest my-app
cd my-app
npx expo install expo-router expo-secure-store
npm i zod @tanstack/react-query
Ten minutes, done, right? That's the scaffold. The boilerplate is everything you write in the next three weeks.
The boilerplate you actually build
Here are four pieces nearly every production app needs. None of them are hard. All of them take longer than planned.
1. Env validation
Skip this and you'll ship a build pointing at undefined/api/users.
// src/env.ts
import { z } from 'zod';
const schema = z.object({
EXPO_PUBLIC_API_URL: z.string().url(),
EXPO_PUBLIC_ENV: z.enum(['development', 'staging', 'production']),
});
// Expo inlines these at build time, so reference them statically
export const env = schema.parse({
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
});
2. Session storage
// src/session.tsx
import * as SecureStore from 'expo-secure-store';
import { createContext, useContext, useEffect, useState } from 'react';
type Session = {
token: string | null;
loading: boolean;
signIn: (token: string) => Promise<void>;
signOut: () => Promise<void>;
};
const Ctx = createContext<Session | null>(null);
const KEY = 'session_token';
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
SecureStore.getItemAsync(KEY)
.then(setToken)
.finally(() => setLoading(false));
}, []);
const signIn = async (t: string) => {
await SecureStore.setItemAsync(KEY, t);
setToken(t);
};
const signOut = async () => {
await SecureStore.deleteItemAsync(KEY);
setToken(null);
};
return (
<Ctx.Provider value={{ token, loading, signIn, signOut }}>
{children}
</Ctx.Provider>
);
}
export const useSession = () => {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useSession must be used inside SessionProvider');
return ctx;
};
3. Route guard
// app/(app)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useSession } from '@/src/session';
export default function AppLayout() {
const { token, loading } = useSession();
if (loading) return null; // keep the splash screen up
if (!token) return <Redirect href="/sign-in" />;
return <Stack />;
}
4. An API client that handles 401s
This is the one everybody underestimates.
// src/api.ts
import { env } from './env';
let refreshing: Promise<string | null> | null = null;
export function createApi(
getToken: () => string | null,
refresh: () => Promise<string | null>,
) {
return async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const call = (token: string | null) =>
fetch(`${env.EXPO_PUBLIC_API_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
let res = await call(getToken());
if (res.status === 401) {
// de-dupe: ten parallel requests should trigger one refresh
refreshing ??= refresh().finally(() => (refreshing = null));
const fresh = await refreshing;
if (fresh) res = await call(fresh);
}
if (!res.ok) throw new Error(`${res.status} ${path}`);
return res.json() as Promise<T>;
};
}
That's four files. You still haven't touched push notifications, deep links, payments, analytics, error reporting, EAS build profiles, or CI.
Do the math honestly
Here's a tiny estimator. Plug in your own numbers; mine are illustrative, not data.
type Item = { name: string; days: number };
const build: Item[] = [
{ name: 'auth + session + guards', days: 3 },
{ name: 'api client + query setup', days: 2 },
{ name: 'env, build profiles, CI', days: 3 },
{ name: 'theming + base components', days: 4 },
{ name: 'push, deep links', days: 3 },
{ name: 'payments', days: 4 },
];
const dayRate = 400; // your cost, not your price
const buildDays = build.reduce((sum, i) => sum + i.days, 0);
const upkeepDaysPerYear = 8; // SDK upgrades, dependency churn
console.log({
buildCost: buildDays * dayRate, // 7600
yearOneCost: (buildDays + upkeepDaysPerYear) * dayRate, // 10800
});
The line most people leave out is upkeepDaysPerYear. Expo ships new SDK versions regularly, and a boilerplate that isn't upgraded is a liability by next year.
What "buy" really means now
"Buy" used to mean one thing: pay for a starter kit repo. Now there are three flavors:
| Option | You get | You give up |
|---|---|---|
| Free OSS template | A starting point, community fixes | Guarantees, consistency |
| Paid starter kit | Integrated auth, payments, support | Freedom from the author's stack choices |
| AI-generated starter | Screens and code shaped to your idea | Some cleanup and review time |
The third is the interesting one. Instead of a generic starter you bend into shape, tools like RapidNative generate React Native + Expo code from a prompt, so the starting point already looks like your app rather than a todo-list demo. You still own and review the code, which matters, because generated code deserves the same scrutiny as a purchased kit.
The decision, in code
type Context = {
appsPerYear: number;
needsToShipInWeeks: number;
stackIsUnusual: boolean; // custom native modules, odd backend
teamKnowsTheStack: boolean;
};
function decide(c: Context): 'build' | 'buy' {
if (c.stackIsUnusual) return 'build'; // no kit will fit
if (c.appsPerYear >= 3 && c.teamKnowsTheStack) return 'build'; // it amortizes
if (c.needsToShipInWeeks <= 6) return 'buy'; // time is the constraint
if (!c.teamKnowsTheStack) return 'buy'; // learn from working code
return 'build';
}
Crude, but it captures the real trade: building pays off through reuse, buying pays off through speed.
How to audit anything you didn't write
Before you commit to a kit or a generated codebase, check:
# How stale is it?
npx expo-doctor
npm outdated
# How much is there to own?
npx cloc src app --exclude-dir=node_modules
# Does it even typecheck and build clean?
npx tsc --noEmit
Then open the four files from earlier: env, session, route guard, API client. If those are clean, the rest usually is. If token refresh is missing or the env is read ad hoc all over the app, walk away.
My take
Build your own boilerplate once, on purpose, so you know what's in one. After that, stop. Your users don't care who wrote your auth provider, and the second time you hand-roll a 401 retry you're just procrastinating on the product.
What's in your starter that you'd never give up? Drop a comment with what you're building and whether you built or bought the base.
Top comments (0)