suzuki-vibe-boilerplate
v1.0.7
Published
Production-ready React Native boilerplate with Clerk, PostHog, RevenueCat, Sentry, and more
Readme
suzuki-vibe-boilerplate
A production-ready React Native starter kit built on Expo. Ships with authentication, analytics, crash reporting, in-app purchases, and push notifications — all wired up and ready to go.
Skip the week of SDK integration hell. Clone, configure your keys, and ship.
What's included
| Feature | SDK | |---|---| | Authentication | Clerk | | Analytics & Feature Flags | PostHog | | Crash Reporting | Sentry | | In-App Purchases | RevenueCat | | Push Notifications | Expo Notifications | | Navigation | Expo Router (file-based) | | Server State | TanStack Query | | UI State | Zustand | | Styling | NativeWind (Tailwind for RN) | | i18n | i18next | | Testing | Vitest |
Requirements
- Node.js 18+
- Expo CLI —
npm install -g expo-cli - EAS CLI —
npm install -g eas-cli(for builds and push notifications) - An iOS simulator or Android emulator (or a physical device)
- Accounts at: Clerk, PostHog, Sentry, RevenueCat
Quick start
1. Clone and install
git clone https://github.com/sgoridotla1/suzuki-vibe-boilerplate.git my-app
cd my-app
npm install2. Set your environment variables
cp .env.local.example .env.localOpen .env.local and fill in your API keys (see Environment variables below for where to find each one):
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
EXPO_PUBLIC_POSTHOG_API_KEY=phc_...
EXPO_PUBLIC_REVENUECAT_IOS_KEY=appl_...
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=goog_...
EXPO_PUBLIC_SENTRY_DSN=https://[email protected]/...
EXPO_PUBLIC_API_BASE_URL=http://localhost:30003. Rename the app
The boilerplate uses placeholder tokens throughout. Replace them with your actual app info:
# Replace __APP_NAME__ in app.json
# Replace __BUNDLE_ID__ with your bundle ID (e.g. com.yourcompany.myapp)
# Replace __BRAND_COLOR__ with your hex color (e.g. #6366f1)4. Run it
npm start # Start the Expo dev server
npm run ios # Open on iOS simulator
npm run android # Open on Android emulatorEnvironment variables
All environment variables live in config.ts — the single source of truth. You never read process.env anywhere else in the codebase.
Required
| Variable | Where to get it |
|---|---|
| EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk Dashboard → Your app → API Keys |
| EXPO_PUBLIC_POSTHOG_API_KEY | PostHog → Project Settings → Project API Key |
| EXPO_PUBLIC_REVENUECAT_IOS_KEY | RevenueCat Dashboard → Project → Apps → iOS |
| EXPO_PUBLIC_REVENUECAT_ANDROID_KEY | RevenueCat Dashboard → Project → Apps → Android |
| EXPO_PUBLIC_SENTRY_DSN | Sentry → Project → Settings → Client Keys (DSN) |
| EXPO_PUBLIC_API_BASE_URL | Your backend URL (no trailing slash) |
Optional
| Variable | Default | Description |
|---|---|---|
| EXPO_PUBLIC_POSTHOG_HOST | https://us.i.posthog.com | Change if self-hosting PostHog |
| EXPO_PUBLIC_BRAND_COLOR | #6366f1 | Primary brand color used in Tailwind |
| EXPO_PUBLIC_APP_SCHEME | Value from app.json | Deep link scheme (e.g. myapp://) |
| SENTRY_AUTH_TOKEN | — | For source map uploads in EAS builds. Set via eas secret:create |
If a required variable is missing, the app throws a descriptive error at startup — you'll know exactly which key to add.
Project structure
├── app/ # Screens (Expo Router file-based routing)
│ ├── _layout.tsx # Root layout — provider composition and SDK init
│ ├── (auth)/ # Unauthenticated routes (sign-in, sign-up)
│ └── (app)/ # Authenticated routes (home, settings, paywall)
│
├── modules/ # Third-party SDK integrations
│ ├── auth/ # Clerk authentication
│ ├── api/ # Fetch client with auth headers
│ ├── analytics/ # PostHog events and feature flags
│ ├── monitoring/ # Sentry crash reporting
│ ├── paywall/ # RevenueCat in-app purchases
│ └── notifications/ # Expo push notifications
│
├── stores/ # Zustand state
│ ├── user.store.ts # User profile (userId, displayName, email)
│ └── app.store.ts # App-level UI state (onboarding, color scheme, errors)
│
├── lib/ # Shared utilities
│ ├── i18n.ts # i18next setup and t() export
│ ├── query-client.ts # TanStack Query shared client
│ └── use-color-scheme.ts # NativeWind-aware color scheme hook
│
├── locales/
│ └── en.json # All user-facing strings
│
├── __tests__/ # Unit tests (Vitest)
├── assets/ # App icons and splash screens
├── config.ts # Environment variable config
└── tailwind.config.js # NativeWind + brand color setupHow modules work
Each third-party SDK lives inside a modules/ folder. The rule is simple: nothing outside a module ever imports from that SDK directly.
// ✅ Correct
import { useAuth, signOut } from '@/modules/auth';
// ❌ Wrong — never import SDKs directly in screens or other modules
import { useUser } from '@clerk/clerk-expo';This means:
- Swapping an SDK only touches one module, not the whole app.
- Tests mock at the module boundary, so tests never know which SDK is underneath.
- Screens stay clean — no business logic, no SDK knowledge.
Screens
Screens in app/ are intentionally thin. They render layout, call hooks from modules and stores, and use t() for strings. That's it.
// app/(app)/settings.tsx
import { View } from 'react-native';
import { AuthGate } from '@/modules/auth';
import { useUserStore, selectDisplayName } from '@/stores/user.store';
import { t } from '@/lib/i18n';
export default function SettingsScreen() {
const displayName = useUserStore(selectDisplayName);
return (
<AuthGate>
<View className="flex-1 p-4">
<Text>{t('settings.greeting', { name: displayName })}</Text>
</View>
</AuthGate>
);
}<AuthGate> redirects unauthenticated users to the sign-in screen automatically.
Authentication
Auth is handled by Clerk, wrapped in modules/auth.
import { useAuth, signOut, AuthGate } from '@/modules/auth';
// In a component
const { isSignedIn, userId } = useAuth();
// Outside a component (e.g. in a query function)
await signOut();The AuthProvider is already set up in app/_layout.tsx. Sign-in and sign-up screens live at app/(auth)/sign-in.tsx and app/(auth)/sign-up.tsx.
API calls
Use the api client from modules/api. It automatically attaches the user's auth token to every request.
import { api } from '@/modules/api';
// In a TanStack Query hook
const { data } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get<Profile>('/me'),
});
// POST
await api.post('/posts', { title: 'Hello world' });Failed requests (non-2xx) throw an ApiError with .status and .body properties.
Analytics
import { track, identify, reset, useFeatureFlag } from '@/modules/analytics';
// Track an event
track('button_pressed', { screen: 'home', button: 'get_started' });
// Identify user after sign-in
identify(userId, { email, plan: 'free' });
// Feature flags
const isNewUiEnabled = useFeatureFlag('new-home-ui');
// Reset on sign-out
reset();In-app purchases
import { usePremium, getOfferings, checkEntitlement } from '@/modules/paywall';
const { isPremium, activeEntitlements } = usePremium();
// Show the paywall screen
import { PaywallScreen } from '@/modules/paywall';
<PaywallScreen
headline="Go Premium"
features={['Unlimited access', 'No ads', 'Priority support']}
ctaLabel="Start free trial"
onPurchase={(pkg) => { /* handle purchase */ }}
onRestore={() => { /* handle restore */ }}
/>Crash reporting
import { captureError, captureMessage, setUser } from '@/modules/monitoring';
// Log an error
try {
await riskyOperation();
} catch (err) {
captureError(err, { screen: 'checkout' });
}
// Attach user context after sign-in
setUser({ id: userId, email });
// Clear on sign-out
setUser(null);Push notifications
import { usePushNotifications, useRegisterPushTokenOnSignIn } from '@/modules/notifications';
// Get the device's push token
const { expoPushToken, isPermissionGranted } = usePushNotifications();
// Automatically register the token when the user signs in
useRegisterPushTokenOnSignIn(async (token) => {
await api.post('/devices', { token });
});State management
Zustand handles local UI and navigation state. TanStack Query handles server data.
// Local state — Zustand
import { useUserStore, selectDisplayName } from '@/stores/user.store';
const displayName = useUserStore(selectDisplayName);
// App state
import { useAppStore, selectColorScheme } from '@/stores/app.store';
const { showError } = useAppStore();
showError('Something went wrong');
// Server state — TanStack Query
import { useQuery } from '@tanstack/react-query';
import { api } from '@/modules/api';
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get<Profile>('/me'),
});i18n
All user-facing strings must use t(). Add new keys to locales/en.json before using them.
import { t } from '@/lib/i18n';
// Simple key
t('common.loading') // → "Loading..."
// With interpolation
t('settings.userId', { userId }) // → "Your ID: abc123"Key naming convention: <screen>.<key> — e.g. settings.signOut, auth.signInTitle.
Styling
NativeWind (Tailwind CSS for React Native) is pre-configured.
<View className="flex-1 bg-white dark:bg-neutral-950 p-4">
<Text className="text-xl font-bold text-brand">Hello</Text>
<Button className="bg-brand rounded-lg px-4 py-2" />
</View>- Brand color: use
bg-brand,text-brand,border-brand - Dark mode: prefix with
dark:— it's automatic based on system setting - Color scheme hook: import
useColorSchemefrom@/lib/use-color-scheme(not fromreact-native)
Testing
npm test # Run all tests once
npm run test:watch # Watch mode
npm run tsc # TypeScript type check
npm run lint # ESLint
npm run format # PrettierTests live in __tests__/ and use Vitest. Each module has its own test file. SDKs are mocked at the module boundary:
// __tests__/analytics.test.ts
vi.mock('posthog-react-native', () => ({
default: vi.fn(() => mockClient),
}));
// Then test the module's public API, not the SDK internalsPre-commit hooks (Husky + lint-staged) run ESLint and Prettier on staged files automatically.
Building for production
This project uses EAS Build.
# Configure EAS for your account
eas build:configure
# Build for iOS
eas build --platform ios --profile production
# Build for Android
eas build --platform android --profile production
# Submit to app stores
eas submit --platform ios
eas submit --platform androidFor production, add your Sentry auth token as an EAS secret so source maps are uploaded automatically:
eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value sntrys_...Adding a new module
- Create
modules/<name>/index.ts - Add a JSDoc comment at the top naming the owned SDK and the boundary rule
- Export only what other parts of the app need — keep SDK types and internals private
- Create
__tests__/<name>.test.tsand mock the SDK at the top withvi.mock() - If the module needs a provider at startup, add it to
app/_layout.tsx
// modules/storage/index.ts
/**
* modules/storage — AsyncStorage wrapper.
* All @react-native-async-storage/async-storage imports stay in this module.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export async function getItem<T>(key: string): Promise<T | null> { ... }
export async function setItem<T>(key: string, value: T): Promise<void> { ... }Adding a new screen
- Create the file under
app/(app)/(authenticated) orapp/(auth)/(unauthenticated) - Wrap authenticated screens with
<AuthGate> - Add any new strings to
locales/en.jsonfirst, then uset()in JSX - Keep the file under ~60 lines — delegate logic to modules or stores
// app/(app)/profile.tsx
import { View, Text } from 'react-native';
import { AuthGate } from '@/modules/auth';
import { useUserStore, selectDisplayName } from '@/stores/user.store';
import { t } from '@/lib/i18n';
export default function ProfileScreen() {
const displayName = useUserStore(selectDisplayName);
return (
<AuthGate>
<View className="flex-1 p-4">
<Text className="text-xl">{t('profile.greeting', { name: displayName })}</Text>
</View>
</AuthGate>
);
}Adding a new config key
- Add the variable to
.env.local.exampleand.env.production.example - Add it to
config.ts— useassertEnv()for required keys, or a?? 'default'expression for optional ones - Add a JSDoc comment describing what the key does
// config.ts
/** Base URL of the image CDN. */
imageCdnUrl: process.env.EXPO_PUBLIC_IMAGE_CDN_URL ?? 'https://cdn.example.com',Never read process.env anywhere except config.ts.
Provider order
Defined in app/_layout.tsx. Outer to inner:
AuthProvider (Clerk)
→ QueryClientProvider (TanStack Query)
→ AnalyticsProvider (PostHog)
→ Stack (Expo Router)SDK initialization order at module load (before first render):
initMonitoring()— Sentry, first so init-time crashes are capturedinitPaywall()— RevenueCat, configured per platforminitAnalytics()— PostHog, client passed toAnalyticsProvider
License
MIT
