| name | expo-env-config |
| description | creating, modifying, or… |
Expo Environment Configuration
Overview
This skill enforces type-safe, validated environment variable management for Expo/React Native using Zod schemas. Environment variables are validated at build time and provide full TypeScript inference, regardless of their source (.env files, EAS Build secrets, CI/CD pipelines, or command-line exports).
Why This Pattern?
Unlike NestJS's @nestjs/config, Expo has no official type-safe env solution. The Zod validation pattern provides:
- Type safety - Full TypeScript inference via
z.infer<typeof schema>
- Build-time validation - Fails fast with clear error messages before deployment
- Source agnostic - Works with
.env, EAS secrets, CI variables
- Testing support - Easy mocking via module aliases
Core Pattern
Environment Schema (src/lib/env.ts)
import { z } from "zod";
const envSchema = z.object({
EXPO_PUBLIC_API_URL: z.string().url(),
EXPO_PUBLIC_APP_ENV: z.enum(["development", "staging", "production"]),
EXPO_PUBLIC_SENTRY_DSN: z.string().optional(),
EXPO_PUBLIC_FEATURE_FLAG: z
.string()
.transform(v => v === "true")
.default("false"),
});
export const env = envSchema.parse(process.env);
export type Env = z.infer<typeof envSchema>;
Usage in Components/Hooks
import { env } from "@/lib/env";
const apiUrl = env.EXPO_PUBLIC_API_URL;
const isDev = env.EXPO_PUBLIC_APP_ENV === "development";
Build-Time Validation (app.config.ts)
For variables needed during the build process, validate in app.config.ts:
const { z } = require("zod");
const buildEnvSchema = z.object({
EXPO_PUBLIC_API_URL: z.string().url(),
EXPO_PUBLIC_APP_ENV: z.enum(["development", "staging", "production"]),
SENTRY_AUTH_TOKEN: z.string().optional(),
});
const env = buildEnvSchema.parse(process.env);
module.exports = {
name: "MyApp",
slug: "my-app",
extra: {
apiUrl: env.EXPO_PUBLIC_API_URL,
appEnv: env.EXPO_PUBLIC_APP_ENV,
},
};
Variable Sources
Environment variables arrive in process.env from multiple sources:
| Source | When Available | How Set |
|---|
.env.local | Local dev | Expo CLI auto-loads |
.env.development | Local dev | Copied to .env.local via npm script |
eas.json env | EAS Build | build.production.env section |
| EAS Secrets | EAS Build | eas secret:create |
| CI Variables | CI builds | GitHub Actions / GitLab CI settings |
The Zod pattern validates process.env directly - it doesn't care how variables got there.
Testing Pattern
Jest Setup (jest.setup.local.ts)
jest.mock("@/lib/env", () => ({
env: {
EXPO_PUBLIC_API_URL: "https://test.example.com",
EXPO_PUBLIC_APP_ENV: "development",
EXPO_PUBLIC_SENTRY_DSN: undefined,
EXPO_PUBLIC_FEATURE_FLAG: false,
},
}));
Override in Specific Tests
import { env } from "@/lib/env";
jest.mock("@/lib/env");
describe("ProductionFeature", () => {
beforeEach(() => {
(env as jest.Mocked<typeof env>).EXPO_PUBLIC_APP_ENV = "production";
});
it("should behave differently in production", () => {
});
});
ESLint Enforcement
This pattern is enforced by ESLint's no-restricted-syntax rule in eslint.config.mjs:
"no-restricted-syntax": [
"error",
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message: "Direct process.env access is forbidden. Import { env } from '@/lib/env' instead.",
},
],
Exceptions (files allowed to use process.env):
lib/env.ts - The env validation module itself
app.config.ts - Expo build config
codegen.ts - GraphQL codegen config
playwright.config.ts - E2E test config
lighthouserc.js - Lighthouse CI config
Core Rules
1. Always Prefix with EXPO_PUBLIC_
Variables without this prefix are not available in client code:
EXPO_PUBLIC_API_URL=https:
API_URL=https:
2. Never Access process.env Directly
Always use the validated env object:
import { env } from "@/lib/env";
const url = env.EXPO_PUBLIC_API_URL;
const url = process.env.EXPO_PUBLIC_API_URL;
3. Validate Early, Fail Fast
Validation happens at module load. If a required variable is missing, the app fails immediately with a clear error rather than at runtime.
4. Use Transforms for Non-String Types
Environment variables are always strings. Use Zod transforms:
const envSchema = z.object({
EXPO_PUBLIC_DEBUG: z
.string()
.transform(v => v === "true")
.default("false"),
EXPO_PUBLIC_TIMEOUT_MS: z
.string()
.transform(v => parseInt(v, 10))
.default("5000"),
EXPO_PUBLIC_ALLOWED_HOSTS: z
.string()
.transform(v => v.split(",").map(s => s.trim()))
.default(""),
});
5. Separate Client vs Build-Only Variables
Keep sensitive build-time variables out of the client schema:
const clientSchema = z.object({
EXPO_PUBLIC_API_URL: z.string().url(),
});
const buildSchema = z.object({
SENTRY_AUTH_TOKEN: z.string(),
EAS_PROJECT_ID: z.string(),
});
File Organization
src/
lib/
env.ts # Main env schema and exports
app.config.ts # Build-time validation (if needed)
.env.localhost # Local development (git-ignored)
.env.development # Development environment
.env.staging # Staging environment
.env.production # Production environment
Detailed Reference
For comprehensive patterns, transforms, and testing examples:
Anti-Patterns to Avoid
Never use process.env directly in components
const Component = () => {
const url = process.env.EXPO_PUBLIC_API_URL;
};
import { env } from "@/lib/env";
const Component = () => {
const url = env.EXPO_PUBLIC_API_URL;
};
Never skip validation for "simple" variables
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3000";
const envSchema = z.object({
EXPO_PUBLIC_API_URL: z.string().url().default("http://localhost:3000"),
});
export const { EXPO_PUBLIC_API_URL: API_URL } = envSchema.parse(process.env);
Never store secrets in EXPO_PUBLIC_ variables
EXPO_PUBLIC_API_SECRET=super-secret-key
SENTRY_AUTH_TOKEN=secret # Build-only, not in bundle
Validation Checklist
When adding or modifying environment variables: