JSON in Expo: AsyncStorage, SecureStore, Expo Router API, and TypeScript
Last updated:
Expo apps handle JSON in four main contexts: fetching from remote APIs, persisting locally with AsyncStorage or expo-secure-store, importing static JSON files at build time, and serving JSON from Expo Router API routes. The fetch API works the same as in web: const res = await fetch(url); const data: Product[] = await res.json(). For local persistence, AsyncStorage.setItem(key, JSON.stringify(value)) stores JSON as a string; AsyncStorage.getItem(key) returns it — always call JSON.parse() on the result. For sensitive data (tokens, credentials), expo-secure-store encrypts values using the device keychain or Keystore — same API but values are stored encrypted. Expo Router (SDK 50+) supports API routes in the app/api/ directory: a file at app/api/products+api.ts handles REST-style requests and returns typed JSON responses using the Response API. TypeScript integration is excellent — expo-secure-store, AsyncStorage, and the fetch Response all have full types. Use Zod for runtime validation of API responses. This guide covers fetch patterns, AsyncStorage JSON, SecureStore, static JSON imports, Expo Router API routes, and TypeScript safety.
Fetching JSON from APIs
Bottom line: fetch in Expo uses the same API as browsers — no extra library needed. Always check res.ok before calling res.json(), add a TypeScript generic for compile-time types, and use Zod safeParse for runtime safety.
The global fetch API is available in Expo SDK 47+ without any polyfill. A GET request takes roughly 3 lines: fetch, res.ok check, and res.json(). The TypeScript generic — const data: Product[] = await res.json() — is compile-time only; it adds 0 bytes to the bundle and performs no runtime check. For POST requests, set Content-Type: application/json and pass JSON.stringify(payload) as the body. Zod's safeParse after res.json() catches API schema mismatches before they cause crashes downstream — particularly important when consuming third-party APIs. The useFetch custom hook pattern extracts loading, error, and data state into a reusable 30-line hook.
import { useState, useEffect } from 'react'
import { z } from 'zod'
// --- TypeScript interface + Zod schema (single source of truth) ---
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
inStock: z.boolean(),
})
const ProductArraySchema = z.array(ProductSchema)
type Product = z.infer<typeof ProductSchema> // derives the TS type from Zod
// --- GET with error handling + Zod runtime validation ---
async function fetchProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/products')
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
const raw = await res.json()
// TypeScript generic alone doesn't validate at runtime — Zod does:
const result = ProductArraySchema.safeParse(raw)
if (!result.success) {
console.error('API shape mismatch:', result.error.flatten())
throw new Error('Unexpected API response shape')
}
return result.data // fully typed: Product[]
}
// --- POST with JSON body ---
async function createProduct(payload: Omit<Product, 'id'>): Promise<Product> {
const res = await fetch('https://api.example.com/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const raw = await res.json()
return ProductSchema.parse(raw) // throws if shape is wrong
}
// --- useEffect + useState loading pattern ---
function ProductsScreen() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchProducts()
.then(setProducts)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
// render products, loading, error states...
}
// --- Reusable useFetch hook ---
function useFetch<T>(fetcher: () => Promise<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
setLoading(true)
fetcher()
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { data, loading, error }
}
// Usage: const { data, loading, error } = useFetch(fetchProducts)AsyncStorage for JSON Persistence
Bottom line: @react-native-async-storage/async-storage is the standard key-value store for non-sensitive JSON in Expo. Always JSON.stringify on write and JSON.parse on read. The 6 MB per-key limit means large datasets need chunking.
Install with npx expo install @react-native-async-storage/async-storage. All operations are asynchronous and return Promises. AsyncStorage.setItem accepts only strings — wrap every object with JSON.stringify. On read, getItem returns null if the key does not exist, so always guard with a conditional before JSON.parse. For multiple keys at once, multiSet and multiGet are more efficient than N individual calls. MMKV (react-native-mmkv) is a synchronous alternative that is 30x faster than AsyncStorage — it uses memory-mapped files and is suitable for performance-sensitive apps with frequent reads and writes. expo-sqlite is the right choice when data is relational or needs querying by field value.
import AsyncStorage from '@react-native-async-storage/async-storage'
// --- Basic set and get ---
async function saveProducts(products: Product[]): Promise<void> {
await AsyncStorage.setItem('products', JSON.stringify(products))
}
async function loadProducts(): Promise<Product[] | null> {
const raw = await AsyncStorage.getItem('products')
return raw ? (JSON.parse(raw) as Product[]) : null
}
// --- Typed generic wrappers (avoids repetitive JSON.stringify/parse) ---
async function storeJson<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value))
}
async function loadJson<T>(key: string): Promise<T | null> {
const raw = await AsyncStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : null
}
// Usage:
await storeJson<Product[]>('products', products)
const cached = await loadJson<Product[]>('products')
// --- Remove and clear ---
await AsyncStorage.removeItem('products') // remove one key
await AsyncStorage.clear() // remove all keys (use with caution)
// --- Chunked storage for data > 6 MB ---
async function storeChunked<T>(baseKey: string, items: T[], chunkSize = 100) {
const chunks = []
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize))
}
const pairs: [string, string][] = chunks.map((chunk, i) => [
`${baseKey}_chunk_${i}`,
JSON.stringify(chunk),
])
pairs.push([`${baseKey}_count`, String(chunks.length)])
await AsyncStorage.multiSet(pairs)
}
// --- MMKV: 30× faster synchronous alternative ---
// import { MMKV } from 'react-native-mmkv'
// const storage = new MMKV()
// storage.set('products', JSON.stringify(products)) // sync
// const raw = storage.getString('products')
// const products = raw ? JSON.parse(raw) as Product[] : nullexpo-secure-store for Sensitive JSON
Bottom line: expo-secure-store encrypts values using the iOS Keychain or Android Keystore. Use it for tokens, credentials, and private keys — not for general app data. Always JSON.stringify objects before writing and JSON.parse on read.
Install with npx expo install expo-secure-store. The API mirrors AsyncStorage — both are async and string-only — but SecureStore values are encrypted at rest by the OS secure enclave, not just the file system. The keychainAccessible option controls when the OS decrypts the value: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY is the most restrictive — the value is inaccessible after a device backup is restored on a different device, making it ideal for session tokens. SecureStore values are limited to 2 KB each — for larger encrypted payloads, encrypt the data yourself with expo-crypto and store only the encryption key in SecureStore. Calling deleteItemAsync removes the value from the Keychain or Keystore, not just AsyncStorage.
import * as SecureStore from 'expo-secure-store'
interface AuthToken {
accessToken: string
refreshToken: string
expiresAt: number // Unix timestamp (ms)
}
// --- Store sensitive JSON ---
async function saveAuthToken(token: AuthToken): Promise<void> {
await SecureStore.setItemAsync(
'authToken',
JSON.stringify(token),
{
// Value is inaccessible after backup restore on a different device
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}
)
}
// --- Read sensitive JSON ---
async function loadAuthToken(): Promise<AuthToken | null> {
const raw = await SecureStore.getItemAsync('authToken')
return raw ? (JSON.parse(raw) as AuthToken) : null
}
// --- Delete on sign-out ---
async function clearAuthToken(): Promise<void> {
await SecureStore.deleteItemAsync('authToken')
}
// --- Usage: check token expiry after loading ---
async function getValidToken(): Promise<AuthToken | null> {
const token = await loadAuthToken()
if (!token) return null
if (token.expiresAt < Date.now()) {
await clearAuthToken() // remove expired token
return null
}
return token
}
// --- keychainAccessible options ---
// SecureStore.AFTER_FIRST_UNLOCK — accessible after first unlock (default)
// SecureStore.WHEN_UNLOCKED — accessible only while device is unlocked
// SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY — locked to this device, not in backup
// SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY — requires device passcode
// Decision: SecureStore vs AsyncStorage
// SecureStore → tokens, passwords, private keys, credentials
// AsyncStorage → user preferences, cached API data, cart state, theme settingsStatic JSON Imports and app.json Config
Bottom line: Metro bundler inlines static JSON imports at build time — zero runtime I/O. Enable resolveJsonModule: true in tsconfig.json for TypeScript. Use app.config.js to merge environment variables into your Expo config dynamically.
Static JSON imports work out of the box in Expo — Metro bundler parses the file at bundle time and inlines the JavaScript object. The TypeScript compiler infers the exact literal type of the JSON, giving autocomplete on every field. app.json is the primary project config — it holds name, version, icon, splash, and platform-specific settings. For dynamic config, rename app.json to app.config.js and export a function that receives { config } and returns a merged config object, which lets you inject environment variables from .env files at build time using process.env.EXPO_PUBLIC_API_URL. At runtime, expo-constants exposes the final config as Constants.expoConfig — read custom fields from Constants.expoConfig?.extra.
// --- Static JSON import: Metro inlines at build time ---
import products from './data/products.json'
// TypeScript infers the type from the file literal — full autocomplete
// --- tsconfig.json: enable JSON module resolution ---
// {
// "compilerOptions": {
// "resolveJsonModule": true, // already set in Expo's default tsconfig
// "esModuleInterop": true
// }
// }
// --- app.json: basic project config (static) ---
// {
// "expo": {
// "name": "MyApp",
// "slug": "my-app",
// "version": "1.0.0",
// "extra": {
// "apiUrl": "https://api.example.com"
// }
// }
// }
// --- app.config.js: dynamic config with env vars ---
// module.exports = ({ config }) => ({
// ...config,
// extra: {
// apiUrl: process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com',
// featureFlags: JSON.parse(process.env.FEATURE_FLAGS ?? '{}'),
// },
// })
// --- Read config at runtime with expo-constants ---
import Constants from 'expo-constants'
const apiUrl: string = Constants.expoConfig?.extra?.apiUrl ?? 'https://api.example.com'
// --- Feature flags from extra (JSON object merged at build time) ---
interface FeatureFlags {
darkMode: boolean
betaFeatures: boolean
}
const flags = Constants.expoConfig?.extra?.featureFlags as FeatureFlags | undefined
// --- Typed wrapper for Constants.extra ---
function getExtraConfig<T>(key: string, fallback: T): T {
return (Constants.expoConfig?.extra?.[key] as T | undefined) ?? fallback
}
const flags2 = getExtraConfig<FeatureFlags>('featureFlags', { darkMode: false, betaFeatures: false })Expo Router API Routes
Bottom line: Expo Router SDK 50+ supports API routes in app/api/ using the +api.ts suffix. Export named functions (GET, POST, etc.) that return a Response. Use Response.json() and request.json() with Zod validation.
A file at app/api/products+api.ts handles requests to /api/products. The file-based routing convention matches pages: app/api/products/[id]+api.ts captures a dynamic segment. Each exported function receives a standard Web API Request object and must return aResponse. Response.json(data) sets Content-Type: application/json automatically. For the POST handler, await request.json() parses the body — always validate with Zod before using it. During development, API routes run on the Expo development server on port 8081. For production, they can be deployed to any Node.js-compatible host or serverless function — Expo's EAS hosting supports them natively.
// app/api/products+api.ts — handles /api/products
import { z } from 'zod'
const ProductSchema = z.object({
name: z.string().min(1),
price: z.number().positive(),
inStock: z.boolean(),
})
// In-memory store for example purposes
const products = [
{ id: 1, name: 'Widget', price: 9.99, inStock: true },
{ id: 2, name: 'Gadget', price: 24.99, inStock: false },
]
// --- GET /api/products ---
export function GET(request: Request) {
const url = new URL(request.url)
const inStock = url.searchParams.get('inStock')
const filtered = inStock !== null
? products.filter((p) => p.inStock === (inStock === 'true'))
: products
return Response.json({ products: filtered, count: filtered.length })
}
// --- POST /api/products ---
export async function POST(request: Request) {
const body = await request.json()
const result = ProductSchema.safeParse(body)
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten().fieldErrors },
{ status: 400 }
)
}
const newProduct = { id: products.length + 1, ...result.data }
products.push(newProduct)
return Response.json({ product: newProduct }, { status: 201 })
}
// ─────────────────────────────────────────────────────────────────────────────
// app/api/products/[id]+api.ts — handles /api/products/:id
// ─────────────────────────────────────────────────────────────────────────────
// Note: use getRouteParams(request) from 'expo-router/server' to read path params
// import { getRouteParams } from 'expo-router/server'
// export function GET(request: Request) {
// const { id } = getRouteParams(request)
// const product = products.find((p) => p.id === Number(id))
// if (!product) return Response.json({ error: 'Not found' }, { status: 404 })
// return Response.json({ product })
// }
// --- Calling an API route from within the Expo app ---
async function fetchLocalProducts() {
const res = await fetch('/api/products?inStock=true')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { products } = await res.json() as { products: typeof products }
return products
}TypeScript and Zod for JSON Safety
Bottom line: TypeScript types are erased at runtime — use Zod schemas as the single source of truth and derive TypeScript types from them with z.infer<typeof Schema>. Use safeParse for API responses, parse for trusted internal data.
Expo ships with TypeScript support out of the box — npx create-expo-app --template generates a fully typed project. Define a Zod schema first and derive the TypeScript type from it: this keeps the runtime validator and the compile-time type in sync with 0 duplication. safeParse returns { success: true, data: T } or { success: false, error: ZodError } — never throws, suitable for user-facing code. parse throws a ZodError — suitable for internal invariants where failure is a programming error. For end-to-end type safety between an Expo app and a Node.js backend, tRPC defines procedures once and auto-generates typed client hooks — no manual interface duplication. Install with npm install @trpc/client @trpc/server @trpc/react-query.
import { z } from 'zod'
// --- Schema-first: define Zod schema, derive TypeScript type ---
const ProductSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(200),
price: z.number().nonnegative(),
inStock: z.boolean(),
tags: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
// TypeScript type derived from Zod — single source of truth:
type Product = z.infer<typeof ProductSchema>
// { id: number; name: string; price: number; inStock: boolean; tags?: string[]; metadata?: Record<string, unknown> }
// --- Generic API response wrapper ---
const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
data: dataSchema,
total: z.number().optional(),
page: z.number().optional(),
})
type ApiResponse<T> = { data: T; total?: number; page?: number }
// --- Typed API client ---
async function fetchProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/products')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const raw = await res.json()
const ResponseSchema = ApiResponseSchema(z.array(ProductSchema))
const result = ResponseSchema.safeParse(raw)
if (!result.success) {
// Log field-level errors — invaluable during development
console.error('Validation errors:', JSON.stringify(result.error.flatten(), null, 2))
throw new Error('Unexpected API response')
}
return result.data.data // typed as Product[]
}
// --- safeParse for user-facing code (never throws) ---
function parseProduct(raw: unknown): Product | null {
const result = ProductSchema.safeParse(raw)
if (!result.success) {
console.warn('Invalid product data:', result.error.message)
return null
}
return result.data
}
// --- parse for internal invariants (throws on failure) ---
function assertProduct(raw: unknown): Product {
return ProductSchema.parse(raw) // ZodError if shape is wrong
}
// --- Partial schema for PATCH (all fields optional) ---
const UpdateProductSchema = ProductSchema.partial().omit({ id: true })
type UpdateProduct = z.infer<typeof UpdateProductSchema>
// --- tRPC for end-to-end type safety (client side) ---
// import { createTRPCReact } from '@trpc/react-query'
// import type { AppRouter } from '../server/router' // import server types
//
// export const trpc = createTRPCReact<AppRouter>()
//
// // In a component:
// const { data } = trpc.products.list.useQuery()
// // data is automatically typed as Product[] from the server router — no manual schemasDefinitions
- AsyncStorage
- A key-value storage system for React Native and Expo apps that persists data across app restarts. Values are always strings — objects must be serialized with
JSON.stringifyand deserialized withJSON.parse. Maximum 6 MB per key. Backed by SQLite on Android and a flat file on iOS. - expo-secure-store
- An Expo library that stores string values encrypted in the iOS Keychain or Android Keystore. Values are encrypted at rest by the OS secure enclave — no manual cryptography needed. Suitable for tokens, credentials, and private keys. Maximum 2 KB per value.
- Expo Router API route
- A server-side request handler defined by placing a
+api.tsfile in theapp/api/directory. Follows the same file-based routing as Expo Router pages. Exports named functions (GET,POST,PUT,DELETE) that receive a Web APIRequestand return aResponse. Available since Expo SDK 50. - app.config.js
- A dynamic alternative to
app.jsonfor Expo project configuration. Exports a function that receives the existing config and returns a merged config object, allowing environment variables and runtime values to be injected at build time. Useful for multi-environment deployments (development, staging, production). - Metro bundler
- The JavaScript bundler used by React Native and Expo. It bundles JavaScript modules and static assets (including JSON files) into a single bundle served to the device. Metro inlines static JSON imports at bundle time, making them available as JavaScript objects with zero runtime I/O.
- MMKV
- A fast key-value storage library for React Native based on WeChat's MMKV storage framework. Operates synchronously using memory-mapped files, making it approximately 30x faster than AsyncStorage. Values are strings, numbers, or booleans — JSON objects require
JSON.stringifyandJSON.parse. Not included in Expo Go; requires a development build.
FAQ
How do I fetch JSON from an API in Expo?
Use the global fetch API — it's available in Expo without any polyfill. Call const res = await fetch(url) and check res.ok before calling const data = await res.json(). Add a TypeScript generic — const data: Product[] = await res.json() — for compile-time types, but pair it with Zod safeParse for actual runtime safety. For POST requests, set Content-Type: application/json and pass body: JSON.stringify(payload). Use the useEffect + useState pattern for loading/error state in components, or extract into a useFetch custom hook for reuse across screens.
How do I persist JSON locally in Expo?
Use @react-native-async-storage/async-storage. Call await AsyncStorage.setItem('key', JSON.stringify(value)) to write, and const raw = await AsyncStorage.getItem('key'); const value = raw ? JSON.parse(raw) : null to read. The 6 MB per-key cap means large datasets need chunking via AsyncStorage.multiSet. Build typed storeJson<T> and loadJson<T> wrappers to keep JSON.stringify and JSON.parse calls centralized. MMKV is a synchronous drop-in replacement that is 30x faster — ideal for apps with frequent storage operations.
How do I store sensitive JSON securely in Expo?
Use expo-secure-store. It encrypts values using the iOS Keychain or Android Keystore — OS-level encryption with no manual cryptography needed. Call await SecureStore.setItemAsync('key', JSON.stringify(obj)) to write and const raw = await SecureStore.getItemAsync('key'); const obj = raw ? JSON.parse(raw) : null to read. Use keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY for maximum security — the value is inaccessible after a backup is restored on a different device. Use SecureStore for tokens, credentials, and private keys; AsyncStorage for non-sensitive preferences and cached data.
How do I create a JSON API endpoint in Expo Router?
Create a file in app/api/ with a +api.ts suffix. The filename maps to the URL:app/api/products+api.ts handles /api/products. Export named functions for each HTTP method: export function GET(request: Request) { return Response.json({ ... }) }. For POST, read the body with const body = await request.json(), validate with Zod, and returnResponse.json({ error }, { status: 400 } on failure. API routes use standard Web APIRequest and Response types. Available since Expo SDK 50.
How do I import a static JSON file in Expo?
Use import data from './data.json'. Metro bundler parses the file at bundle time and inlines the object — zero runtime I/O. In TypeScript, ensure resolveJsonModule: true is set in tsconfig.json (Expo's default config already includes this). The TypeScript compiler infers the exact type from the file. For Expo project configuration, use app.json for static config or app.config.js for dynamic config with environment variables. Access config fields at runtime via Constants.expoConfig?.extra?.myField from expo-constants.
How do I add TypeScript types and runtime validation to Expo JSON?
Define a Zod schema first and derive the TypeScript type from it with type Product = z.infer<typeof ProductSchema> — this keeps both in sync from a single source of truth. After res.json(), call ProductSchema.safeParse(data) and check result.success before using result.data. Use safeParse for external API responses (never throws), parse for internal invariants (throws on failure). For end-to-end types between an Expo app and a Node.js backend, tRPC defines procedures once and generates typed client hooks automatically — install @trpc/client, @trpc/server, and @trpc/react-query.
Building a React Native app without Expo?
The AsyncStorage and Zod patterns in this guide apply directly to bare React Native projects. For platform-specific JSON handling patterns, see JSON in React Native and JSON in Mobile Apps.
Open JSON FormatterFurther reading and primary sources
- AsyncStorage documentation — Official @react-native-async-storage/async-storage docs covering installation, API reference, limits, and best practices for React Native and Expo apps
- expo-secure-store documentation — Official Expo docs for expo-secure-store: API reference, keychainAccessible options, platform behavior differences between iOS Keychain and Android Keystore
- Expo Router API routes — Official Expo Router documentation for creating server-side API routes with the +api.ts convention, request handling, and deployment options
- Zod documentation — Official Zod docs covering schema definition, safeParse vs parse, z.infer for TypeScript type derivation, and integration with fetch and form validation
- MMKV for React Native — react-native-mmkv GitHub repo and docs: synchronous storage, 30x performance over AsyncStorage, TypeScript support, and migration guide from AsyncStorage