Environment Variables and Configuration
Complete guide to managing environment variables with Zod validation, type-checking, and multi-environment support in Caracal starter.
Managing environment variables in React Native projects is essential but challenging. The Caracal starter includes a production-ready setup with Zod validation, TypeScript type-checking, and multi-environment support (development, staging, production).
Overview
The environment variable system provides:
- Type-safe variables with automatic TypeScript inference
- Runtime validation using Zod schemas
- Multi-environment support (dev/staging/prod)
- Security by splitting client and build-time variables
- Clear error messages when variables are missing or invalid
Key Files:
env.js- Root file that loads and validates all environment variablessrc/lib/env.js- Client-side re-export for use in app code.env.{APP_ENV}- Environment-specific configuration files
This setup is inspired by T3 Stack's excellent environment variable system.
How It Works
Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ .env.{APP_ENV} files │
│ ├── .env.development │
│ ├── .env.staging │
│ └── .env.production │
└──────────────────┬──────────────────────────────────────┘
│ Loaded by dotenv
▼
┌─────────────────────────────────────────────────────────┐
│ env.js (Root) │
│ ├── Defines Zod schemas (client + buildTime) │
│ ├── Validates all variables │
│ └── Exports: Env, ClientEnv, withEnvSuffix │
└──────────────────┬──────────────────────────────────────┘
│
┌──────────┴──────────┐
│ │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ app.config.ts │ │ src/lib/env.js │
│ (Build time) │ │ (Client runtime) │
│ Uses: Env │ │ Re-exports: │
│ ClientEnv│ │ ClientEnv │
└────────────────┘ └─────────┬────────┘
│
▼
┌───────────────┐
│ Your App Code │
│ import @env │
└───────────────┘Variable Types
1. Client Variables (accessible in src/ folder):
- Exposed to the client-side app
- Used in your React components and logic
- Passed via
app.config.ts→extrafield - Import from
@envin your code
2. Build-Time Variables (only in app.config.ts):
- Used during the build process
- Never exposed to the client
- For sensitive data like API keys for build tools
- Examples: Sentry auth tokens, EAS project secrets
Quick Start
Accessing Environment Variables
In your app code (anywhere in src/):
import { Env } from '@env';
// Access variables
const apiUrl = Env.API_URL;
const appName = Env.NAME;
const version = Env.VERSION;Switching Environments
Use the APP_ENV variable to load different configurations:
# Development (default)
pnpm start
# Staging
APP_ENV=staging pnpm start
# Production
APP_ENV=production pnpm start
# Clear cache when switching
pnpm start -cFile Structure
Root env.js File
The root env.js has three main parts:
Part 1: Load Environment File
const APP_ENV = process.env.APP_ENV ?? 'development';
const envPath = path.resolve(__dirname, `.env.${APP_ENV}`);
require('dotenv').config({
path: envPath,
});- Reads
APP_ENVvariable (defaults todevelopment) - Loads corresponding
.env.{APP_ENV}file - Uses
dotenvpackage to parse variables
Part 2: Define Zod Schemas
const client = z.object({
APP_ENV: z.enum(['development', 'staging', 'production']),
NAME: z.string(),
SCHEME: z.string(),
BUNDLE_ID: z.string(),
PACKAGE: z.string(),
VERSION: z.string(),
API_URL: z.string(),
VAR_NUMBER: z.number(),
VAR_BOOL: z.boolean(),
});
const buildTime = z.object({
EXPO_ACCOUNT_OWNER: z.string(),
EAS_PROJECT_ID: z.string(),
SECRET_KEY: z.string(),
});Client Schema:
- Variables used in your app code
- Validated as specific types (string, number, boolean, enum)
- Automatically inferred to TypeScript types
Build-Time Schema:
- Variables only used in
app.config.ts - Sensitive data never exposed to client
- For build tools and CI/CD
Part 3: Create Environment Objects
const _clientEnv = {
APP_ENV,
NAME: NAME,
SCHEME: SCHEME,
BUNDLE_ID: withEnvSuffix(BUNDLE_ID),
PACKAGE: withEnvSuffix(PACKAGE),
VERSION: packageJSON.version,
API_URL: process.env.API_URL,
VAR_NUMBER: Number(process.env.VAR_NUMBER),
VAR_BOOL: process.env.VAR_BOOL === 'true',
};
const _buildTimeEnv = {
EXPO_ACCOUNT_OWNER,
EAS_PROJECT_ID,
SECRET_KEY: process.env.SECRET_KEY,
};Key points:
withEnvSuffix()adds environment suffix to bundle ID/package- Type conversions:
Number()for numbers,=== 'true'for booleans - Static variables (NAME, BUNDLE_ID) defined in env.js directly
Static Variables
Some variables are defined directly in env.js rather than .env files:
const BUNDLE_ID = 'com.caracal';
const PACKAGE = 'com.caracal';
const NAME = 'CaracalApp';
const EXPO_ACCOUNT_OWNER = 'leanhtuan1994';
const EAS_PROJECT_ID = 'c3e1075b-6fe7-4686-aa49-35b46a229044';
const SCHEME = 'caracalApp';Why static?
- These rarely change and are tied to the app's identity
- Used by
withEnvSuffix()to create environment-specific identifiers
Environment-Specific Identifiers:
The withEnvSuffix() function appends environment to bundle IDs:
const withEnvSuffix = (name) => {
return APP_ENV === 'production' ? name : `${name}.${APP_ENV}`;
};
// Results:
// Development: com.caracal.development
// Staging: com.caracal.staging
// Production: com.caracal (no suffix)Benefit: Install dev, staging, and production builds side-by-side on the same device.
Adding a New Environment Variable
Follow these steps to add a new variable:
Add to Zod Schema
Choose the correct schema based on usage:
For client-side variables (used in src/ folder):
// env.js
const client = z.object({
// ... existing variables
NEW_API_KEY: z.string().min(1),
FEATURE_FLAG: z.boolean(),
MAX_RETRIES: z.number(),
});For build-time only variables (used in app.config.ts):
// env.js
const buildTime = z.object({
// ... existing variables
SENTRY_AUTH_TOKEN: z.string().min(1),
BUILD_NUMBER: z.number().optional(),
});Zod Type Reference:
z.string() // Any string (can be empty)
z.string().min(1) // Required non-empty string
z.string().email() // Email format validation
z.string().url() // URL format validation
z.number() // Numeric value
z.boolean() // Boolean value
z.enum(['a', 'b', 'c']) // One of specific values
z.string().optional() // Optional stringAdd to Environment Object
For client variables:
// env.js
const _clientEnv = {
// ... existing variables
NEW_API_KEY: process.env.NEW_API_KEY,
FEATURE_FLAG: process.env.FEATURE_FLAG === 'true',
MAX_RETRIES: Number(process.env.MAX_RETRIES),
};Type Conversion Examples:
// Strings - no conversion needed
MY_STRING: process.env.MY_STRING,
// Numbers - use Number() constructor
MY_NUMBER: Number(process.env.MY_NUMBER),
// Booleans - compare to 'true' string
MY_BOOL: process.env.MY_BOOL === 'true',
// Optional values - check existence
MY_OPTIONAL: process.env.MY_OPTIONAL || undefined,All environment variables are strings by default. You must convert them to the correct type when reading from process.env.
Add to .env Files
Add the variable to all environment files:
# .env.development
NEW_API_KEY=dev-api-key-12345
FEATURE_FLAG=true
MAX_RETRIES=3# .env.staging
NEW_API_KEY=staging-api-key-67890
FEATURE_FLAG=true
MAX_RETRIES=5# .env.production
NEW_API_KEY=prod-api-key-abcdef
FEATURE_FLAG=false
MAX_RETRIES=10If you don't commit .env files to your repo (recommended for security), make sure to configure them in your CI/CD pipeline. See App Releasing Process for GitHub Actions setup.
Rebuild Native Code
# Clear cache and rebuild
pnpm prebuild
# Or rebuild for specific platform
pnpm prebuild:stagingUse in Your Code
Client variables:
import { Env } from '@env';
export function MyComponent() {
const apiKey = Env.NEW_API_KEY;
const isFeatureEnabled = Env.FEATURE_FLAG;
const retries = Env.MAX_RETRIES;
return (
<View>
<Text>API Key: {apiKey}</Text>
<Text>Feature: {isFeatureEnabled ? 'ON' : 'OFF'}</Text>
<Text>Max Retries: {retries}</Text>
</View>
);
}Build-time variables (only in app.config.ts):
// app.config.ts
import { Env } from './env';
export default {
// ...
hooks: {
postPublish: [
{
file: 'sentry-expo/upload-sourcemaps',
config: {
authToken: Env.SENTRY_AUTH_TOKEN,
},
},
],
},
};TypeScript Integration
Automatic Type Inference
Zod automatically infers TypeScript types from schemas:
type ClientEnv = {
APP_ENV: 'development' | 'staging' | 'production';
NAME: string;
SCHEME: string;
BUNDLE_ID: string;
PACKAGE: string;
VERSION: string;
API_URL: string;
VAR_NUMBER: number;
VAR_BOOL: boolean;
};Type-Safe Access
import { Env } from '@env';
const url: string = Env.API_URL; // ✅ Valid
const num: number = Env.VAR_NUMBER; // ✅ Valid
const bool: boolean = Env.VAR_BOOL; // ✅ Valid
const invalid = Env.DOES_NOT_EXIST; // ❌ Type errorType Checking Enforcement
If you add a variable to the schema but forget to add it to the env object, TypeScript will show an error:
/**
* @type {Record<keyof z.infer<typeof client>, unknown>}
*/
const _clientEnv = {
// Must include all keys from schema
};Validation and Error Handling
Runtime Validation
When the app starts, Zod validates all environment variables:
if (parsed.success === false) {
console.error(
'❌ Invalid environment variables:',
parsed.error.flatten().fieldErrors,
`\n❌ Missing variables in .env.${APP_ENV} file...`
);
throw new Error('Invalid environment variables...');
}Example Error Messages
Missing variable:
❌ Invalid environment variables: { API_URL: [ 'Required' ] }
❌ Missing variables in .env.development file, Make sure all required variables are defined in the .env.development file.
💡 Tip: If you recently updated the .env.development file and the error still persists, try restarting the server with the -c flag to clear the cache.Wrong type:
❌ Invalid environment variables: { VAR_NUMBER: [ 'Expected number, received string' ] }Usage in app.config.ts
import type { ConfigContext, ExpoConfig } from '@expo/config';
import { ClientEnv, Env } from './env';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: Env.NAME,
owner: Env.EXPO_ACCOUNT_OWNER,
scheme: Env.SCHEME,
version: Env.VERSION,
ios: {
bundleIdentifier: Env.BUNDLE_ID,
},
android: {
package: Env.PACKAGE,
},
extra: {
...ClientEnv, // ✅ Pass all client vars to app
},
});Key points:
- Import
Envfor all variables (client + build-time) - Import
ClientEnvto pass client variables to the app - Spread
ClientEnvinextrafield to make variables accessible in app code - Build-time variables (like
EXPO_ACCOUNT_OWNER) are NOT inClientEnv
Environment File Examples
.env.development
# API Configuration
API_URL=https://dev-api.example.com
# Feature Flags
VAR_BOOL=true
# Configuration
VAR_NUMBER=42
# Build Time Only (not accessible in app)
SECRET_KEY=dev-secret-key-12345.env.staging
API_URL=https://staging-api.example.com
VAR_BOOL=true
VAR_NUMBER=100
SECRET_KEY=staging-secret-key-67890.env.production
API_URL=https://api.example.com
VAR_BOOL=false
VAR_NUMBER=500
SECRET_KEY=prod-secret-key-abcdefCommon Patterns
API Client Configuration
// src/api/common/client.tsx
import { Env } from '@env';
import axios from 'axios';
export const client = axios.create({
baseURL: Env.API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});Feature Flags
// src/lib/features.ts
import { Env } from '@env';
export const features = {
enableNewUI: Env.FEATURE_FLAG,
maxRetries: Env.MAX_RETRIES,
apiUrl: Env.API_URL,
};Environment-Specific Behavior
import { Env } from '@env';
export function MyComponent() {
const isDev = Env.APP_ENV === 'development';
const isProd = Env.APP_ENV === 'production';
if (isDev) {
console.log('Debug info:', Env.API_URL);
}
return (
<View>
{!isProd && (
<Text className="bg-yellow-200 p-2">
Environment: {Env.APP_ENV}
</Text>
)}
</View>
);
}Best Practices
DO:
- Use Zod schemas for all environment variables
- Add variables to ALL
.envfiles (dev, staging, prod) - Convert types explicitly (
Number(),=== 'true') - Use client schema for app code variables only
- Keep secrets in buildTime schema only
- Commit
.env.examplebut not actual.envfiles - Clear cache when changing env files:
pnpm start -c
DON'T:
- Don't skip type conversion — all env vars are strings
- Don't use client vars for secrets — use buildTime schema
- Don't commit
.envfiles with sensitive data - Don't access
process.envdirectly in app code — useEnvfrom@env - Don't forget to rebuild after adding variables
Troubleshooting
Issue: Variables not updating
# Clear cache and restart
pnpm start -c
# Or rebuild native code
pnpm prebuildIssue: TypeScript errors after adding variable
Make sure the variable exists in BOTH the schema and the env object:
// 1. Add to schema
const client = z.object({
NEW_VAR: z.string(),
});
// 2. Add to env object
const _clientEnv = {
NEW_VAR: process.env.NEW_VAR,
};Issue: Cannot access variable in app code
Variable is only in buildTime schema — move to client schema if needed in app:
// ❌ Wrong - in buildTime only
const buildTime = z.object({
API_URL: z.string(),
});
// ✅ Correct - in client schema
const client = z.object({
API_URL: z.string(),
});Security Considerations
Never expose these in client schema:
- API keys for third-party services
- Database credentials
- Secret signing keys
- Private tokens
- Sentry auth tokens
- EAS submit credentials
These are safe for client:
- API URLs (public endpoints)
- App configuration (feature flags, limits)
- Public API keys (like Google Maps API key with restrictions)
- App metadata (name, version, scheme)
Using Secrets in CI/CD
For GitHub Actions:
# .github/workflows/eas-build.yml
- name: Create .env file
run: |
cat > .env.production << EOF
API_URL=${{ secrets.API_URL }}
VAR_NUMBER=500
VAR_BOOL=false
SECRET_KEY=${{ secrets.SECRET_KEY }}
EOFSee App Releasing Process for complete CI/CD setup.
Resources
- Zod Documentation: https://zod.dev/
- T3 Stack Env System: https://create.t3.gg/en/usage/env-variables
- Expo Environment Variables: https://docs.expo.dev/guides/environment-variables/
- dotenv Package: https://www.npmjs.com/package/dotenv
Summary
Caracal starter provides a robust environment variable system with:
- Zod validation - Runtime type checking with clear errors
- TypeScript inference - Automatic types from schemas
- Multi-environment - Dev, staging, production configs
- Security - Client vs build-time variable separation
- Type safety - No
anytypes, full autocomplete
Quick reference:
- Add to
clientschema for app usage - Add to
buildTimeschema for build-only usage - Convert types:
Number(),=== 'true' - Import:
import { Env } from '@env' - Switch environments:
APP_ENV=staging pnpm start