Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Official Expo skill for networking and data fetching patterns in Expo/React Native apps.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/expo-router-loaders.md
1# Expo Router Data Loaders23Route-level data loading for web apps using Expo SDK 55+. Loaders are async functions exported from route files that load data before the route renders, following the Remix/React Router loader model.45**Dual execution model:**67- **Initial page load (SSR):** The loader runs server-side. Its return value is serialized as JSON and embedded in the HTML response.8- **Client-side navigation:** The browser fetches the loader data from the server via HTTP. The route renders once the data arrives.910You write one function and the framework manages when and how it executes.1112## Configuration1314**Requirements:** Expo SDK 55+, web output mode (`npx expo serve` or `npx expo export --platform web`) set in `app.json` or `app.config.js`.1516**Server rendering:**1718```json19{20"expo": {21"web": {22"output": "server"23},24"plugins": [25["expo-router", {26"unstable_useServerDataLoaders": true,27"unstable_useServerRendering": true28}]29]30}31}32```3334**Static/SSG:**3536```json37{38"expo": {39"web": {40"output": "static"41},42"plugins": [43["expo-router", {44"unstable_useServerDataLoaders": true45}]46]47}48}49```5051| | `"server"` | `"static"` |52|---|-----------|------------|53| `unstable_useServerDataLoaders` | Required | Required |54| `unstable_useServerRendering` | Required | Not required |55| Loader runs on | Live server (every request) | Build time (static generation) |56| `request` object | Full access (headers, cookies) | Not available |57| Hosting | Node.js server (EAS Hosting) | Any static host (Netlify, Vercel, S3) |5859## Imports6061Loaders use two packages:6263- **`expo-router`** — `useLoaderData` hook64- **`expo-server`** — `LoaderFunction` type, `StatusError`, `setResponseHeaders`. Always available (dependency of `expo-router`), no install needed.6566## Basic Loader6768For loaders without params, a plain async function works:6970```tsx71// app/posts/index.tsx72import { Suspense } from "react";73import { useLoaderData } from "expo-router";74import { ActivityIndicator, View, Text } from "react-native";7576export async function loader() {77const response = await fetch("https://api.example.com/posts");78const posts = await response.json();79return { posts };80}8182function PostList() {83const { posts } = useLoaderData<typeof loader>();8485return (86<View>87{posts.map((post) => (88<Text key={post.id}>{post.title}</Text>89))}90</View>91);92}9394export default function Posts() {95return (96<Suspense fallback={<ActivityIndicator size="large" />}>97<PostList />98</Suspense>99);100}101```102103`useLoaderData` is typed via `typeof loader` — the generic parameter infers the return type.104105## Dynamic Routes106107For loaders with params, use the `LoaderFunction<T>` type from `expo-server`. The first argument is the request (an immutable `Request`-like object, or `undefined` in static mode). The second is `params` (`Record<string, string | string[]>`), which contains **path parameters only**. Access individual params with a cast like `params.id as string`. For query parameters, use `new URL(request.url).searchParams`:108109```tsx110// app/posts/[id].tsx111import { Suspense } from "react";112import { useLoaderData } from "expo-router";113import { StatusError, type LoaderFunction } from "expo-server";114import { ActivityIndicator, View, Text } from "react-native";115116type Post = {117id: number;118title: string;119body: string;120};121122export const loader: LoaderFunction<{ post: Post }> = async (123request,124params,125) => {126const id = params.id as string;127const response = await fetch(`https://api.example.com/posts/${id}`);128129if (!response.ok) {130throw new StatusError(404, `Post ${id} not found`);131}132133const post: Post = await response.json();134return { post };135};136137function PostContent() {138const { post } = useLoaderData<typeof loader>();139140return (141<View>142<Text>{post.title}</Text>143<Text>{post.body}</Text>144</View>145);146}147148export default function PostDetail() {149return (150<Suspense fallback={<ActivityIndicator size="large" />}>151<PostContent />152</Suspense>153);154}155```156157Catch-all routes access `params.slug` the same way:158159```tsx160// app/docs/[...slug].tsx161import { type LoaderFunction } from "expo-server";162163type Doc = { title: string; content: string };164165export const loader: LoaderFunction<{ doc: Doc }> = async (request, params) => {166const slug = params.slug as string[];167const path = slug.join("/");168const doc = await fetchDoc(path);169return { doc };170};171```172173Query parameters are available via the `request` object (server output mode only):174175```tsx176// app/search.tsx177import { type LoaderFunction } from "expo-server";178179export const loader: LoaderFunction<{ results: any[]; query: string }> = async (request) => {180// Assuming request.url is `/search?q=expo&page=2`181const url = new URL(request!.url);182const query = url.searchParams.get("q") ?? "";183const page = Number(url.searchParams.get("page") ?? "1");184185const results = await fetchSearchResults(query, page);186return { results, query };187};188```189190## Server-Side Secrets & Request Access191192Loaders run on the server, so you can access secrets and server-only resources directly:193194```tsx195// app/dashboard.tsx196import { type LoaderFunction } from "expo-server";197198export const loader: LoaderFunction<{ balance: any; isAuthenticated: boolean }> = async (199request,200params,201) => {202const data = await fetch("https://api.stripe.com/v1/balance", {203headers: {204Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,205},206});207208const sessionToken = request?.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];209210const balance = await data.json();211return { balance, isAuthenticated: !!sessionToken };212};213```214215The `request` object is available in server output mode. In static output mode, `request` is always `undefined`.216217## Response Utilities218219### Setting Response Headers220221```tsx222// app/products.tsx223import { setResponseHeaders } from "expo-server";224225export async function loader() {226setResponseHeaders({227"Cache-Control": "public, max-age=300",228});229230const products = await fetchProducts();231return { products };232}233```234235### Throwing HTTP Errors236237```tsx238// app/products/[id].tsx239import { StatusError, type LoaderFunction } from "expo-server";240241export const loader: LoaderFunction<{ product: Product }> = async (request, params) => {242const id = params.id as string;243const product = await fetchProduct(id);244245if (!product) {246throw new StatusError(404, "Product not found");247}248249return { product };250};251```252253## Suspense & Error Boundaries254255### Loading States with Suspense256257`useLoaderData()` suspends during client-side navigation. Push it into a child component and wrap with `<Suspense>`:258259```tsx260// app/posts/index.tsx261import { Suspense } from "react";262import { useLoaderData } from "expo-router";263import { ActivityIndicator, View, Text } from "react-native";264265export async function loader() {266const response = await fetch("https://api.example.com/posts");267return { posts: await response.json() };268}269270function PostList() {271const { posts } = useLoaderData<typeof loader>();272273return (274<View>275{posts.map((post) => (276<Text key={post.id}>{post.title}</Text>277))}278</View>279);280}281282export default function Posts() {283return (284<Suspense285fallback={286<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>287<ActivityIndicator size="large" />288</View>289}290>291<PostList />292</Suspense>293);294}295```296297The `<Suspense>` boundary must be above the component calling `useLoaderData()`. On initial page load the data is already in the HTML, suspension only occurs during client-side navigation.298299### Error Boundaries300301```tsx302// app/posts/[id].tsx303export function ErrorBoundary({ error }: { error: Error }) {304return (305<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>306<Text>Error: {error.message}</Text>307</View>308);309}310```311312When a loader throws (including `StatusError`), the nearest `ErrorBoundary` catches it.313314## Static vs Server Rendering315316| | Server (`"server"`) | Static (`"static"`) |317|---|---|---|318| **When loader runs** | Every request (live) | At build time (`npx expo export`) |319| **Data freshness** | Fresh on initial server request | Stale until next build |320| **`request` object** | Full access | Not available |321| **Hosting** | Node.js server (EAS Hosting) | Any static host |322| **Use case** | Personalized/dynamic content | Marketing pages, blogs, docs |323324**Choose server** when data changes frequently or content is personalized (cookies, auth, headers).325326**Choose static** when content is the same for all users and changes infrequently.327328## Best Practices329330- Loaders are web-only; use client-side fetching (React Query, fetch) for native331- Loaders cannot be used in `_layout` files — only in route files332- Use `LoaderFunction<T>` from `expo-server` to type loaders that use params333- The request object is immutable — use optional chaining (`request?.headers`) as it may be `undefined` in static mode334- Return only JSON-serializable values (no `Date`, `Map`, `Set`, class instances, functions)335- Use non-prefixed `process.env` vars for secrets in loaders, not `EXPO_PUBLIC_` (which is embedded in the client bundle)336- Use `StatusError` from `expo-server` for HTTP error responses337- Use `setResponseHeaders` from `expo-server` to set headers338- Export `ErrorBoundary` from route files to handle loader failures gracefully339- Validate and sanitize user input (params, query strings) before using in database queries or API calls340- Handle errors gracefully with try/catch; log server-side for debugging341- Loader data is currently cached for the session. This is a known limitation that will be lifted in a future release342