Files
lamp/.agents/skills/native-data-fetching/references/expo-router-loaders.md
Seven 8963f777ee Add PostCSS configuration and skills lock file
- Created a new PostCSS configuration file to integrate Tailwind CSS.
- Added a skills lock file containing various Expo skills with their respective source and computed hashes.
2026-03-09 06:41:01 +07:00

9.6 KiB

Expo Router Data Loaders

Route-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.

Dual execution model:

  • Initial page load (SSR): The loader runs server-side. Its return value is serialized as JSON and embedded in the HTML response.
  • Client-side navigation: The browser fetches the loader data from the server via HTTP. The route renders once the data arrives.

You write one function and the framework manages when and how it executes.

Configuration

Requirements: Expo SDK 55+, web output mode (npx expo serve or npx expo export --platform web) set in app.json or app.config.js.

Server rendering:

{
  "expo": {
    "web": {
      "output": "server"
    },
    "plugins": [
      ["expo-router", {
        "unstable_useServerDataLoaders": true,
        "unstable_useServerRendering": true
      }]
    ]
  }
}

Static/SSG:

{
  "expo": {
    "web": {
      "output": "static"
    },
    "plugins": [
      ["expo-router", {
        "unstable_useServerDataLoaders": true
      }]
    ]
  }
}
"server" "static"
unstable_useServerDataLoaders Required Required
unstable_useServerRendering Required Not required
Loader runs on Live server (every request) Build time (static generation)
request object Full access (headers, cookies) Not available
Hosting Node.js server (EAS Hosting) Any static host (Netlify, Vercel, S3)

Imports

Loaders use two packages:

  • expo-routeruseLoaderData hook
  • expo-serverLoaderFunction type, StatusError, setResponseHeaders. Always available (dependency of expo-router), no install needed.

Basic Loader

For loaders without params, a plain async function works:

// app/posts/index.tsx
import { Suspense } from "react";
import { useLoaderData } from "expo-router";
import { ActivityIndicator, View, Text } from "react-native";

export async function loader() {
  const response = await fetch("https://api.example.com/posts");
  const posts = await response.json();
  return { posts };
}

function PostList() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <View>
      {posts.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

export default function Posts() {
  return (
    <Suspense fallback={<ActivityIndicator size="large" />}>
      <PostList />
    </Suspense>
  );
}

useLoaderData is typed via typeof loader — the generic parameter infers the return type.

Dynamic Routes

For 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:

// app/posts/[id].tsx
import { Suspense } from "react";
import { useLoaderData } from "expo-router";
import { StatusError, type LoaderFunction } from "expo-server";
import { ActivityIndicator, View, Text } from "react-native";

type Post = {
  id: number;
  title: string;
  body: string;
};

export const loader: LoaderFunction<{ post: Post }> = async (
  request,
  params,
) => {
  const id = params.id as string;
  const response = await fetch(`https://api.example.com/posts/${id}`);

  if (!response.ok) {
    throw new StatusError(404, `Post ${id} not found`);
  }

  const post: Post = await response.json();
  return { post };
};

function PostContent() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <View>
      <Text>{post.title}</Text>
      <Text>{post.body}</Text>
    </View>
  );
}

export default function PostDetail() {
  return (
    <Suspense fallback={<ActivityIndicator size="large" />}>
      <PostContent />
    </Suspense>
  );
}

Catch-all routes access params.slug the same way:

// app/docs/[...slug].tsx
import { type LoaderFunction } from "expo-server";

type Doc = { title: string; content: string };

export const loader: LoaderFunction<{ doc: Doc }> = async (request, params) => {
  const slug = params.slug as string[];
  const path = slug.join("/");
  const doc = await fetchDoc(path);
  return { doc };
};

Query parameters are available via the request object (server output mode only):

// app/search.tsx
import { type LoaderFunction } from "expo-server";

export const loader: LoaderFunction<{ results: any[]; query: string }> = async (request) => {
  // Assuming request.url is `/search?q=expo&page=2`
  const url = new URL(request!.url);
  const query = url.searchParams.get("q") ?? "";
  const page = Number(url.searchParams.get("page") ?? "1");

  const results = await fetchSearchResults(query, page);
  return { results, query };
};

Server-Side Secrets & Request Access

Loaders run on the server, so you can access secrets and server-only resources directly:

// app/dashboard.tsx
import { type LoaderFunction } from "expo-server";

export const loader: LoaderFunction<{ balance: any; isAuthenticated: boolean }> = async (
  request,
  params,
) => {
  const data = await fetch("https://api.stripe.com/v1/balance", {
    headers: {
      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
    },
  });

  const sessionToken = request?.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];

  const balance = await data.json();
  return { balance, isAuthenticated: !!sessionToken };
};

The request object is available in server output mode. In static output mode, request is always undefined.

Response Utilities

Setting Response Headers

// app/products.tsx
import { setResponseHeaders } from "expo-server";

export async function loader() {
  setResponseHeaders({
    "Cache-Control": "public, max-age=300",
  });

  const products = await fetchProducts();
  return { products };
}

Throwing HTTP Errors

// app/products/[id].tsx
import { StatusError, type LoaderFunction } from "expo-server";

export const loader: LoaderFunction<{ product: Product }> = async (request, params) => {
  const id = params.id as string;
  const product = await fetchProduct(id);

  if (!product) {
    throw new StatusError(404, "Product not found");
  }

  return { product };
};

Suspense & Error Boundaries

Loading States with Suspense

useLoaderData() suspends during client-side navigation. Push it into a child component and wrap with <Suspense>:

// app/posts/index.tsx
import { Suspense } from "react";
import { useLoaderData } from "expo-router";
import { ActivityIndicator, View, Text } from "react-native";

export async function loader() {
  const response = await fetch("https://api.example.com/posts");
  return { posts: await response.json() };
}

function PostList() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <View>
      {posts.map((post) => (
        <Text key={post.id}>{post.title}</Text>
      ))}
    </View>
  );
}

export default function Posts() {
  return (
    <Suspense
      fallback={
        <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
          <ActivityIndicator size="large" />
        </View>
      }
    >
      <PostList />
    </Suspense>
  );
}

The <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.

Error Boundaries

// app/posts/[id].tsx
export function ErrorBoundary({ error }: { error: Error }) {
  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <Text>Error: {error.message}</Text>
    </View>
  );
}

When a loader throws (including StatusError), the nearest ErrorBoundary catches it.

Static vs Server Rendering

Server ("server") Static ("static")
When loader runs Every request (live) At build time (npx expo export)
Data freshness Fresh on initial server request Stale until next build
request object Full access Not available
Hosting Node.js server (EAS Hosting) Any static host
Use case Personalized/dynamic content Marketing pages, blogs, docs

Choose server when data changes frequently or content is personalized (cookies, auth, headers).

Choose static when content is the same for all users and changes infrequently.

Best Practices

  • Loaders are web-only; use client-side fetching (React Query, fetch) for native
  • Loaders cannot be used in _layout files — only in route files
  • Use LoaderFunction<T> from expo-server to type loaders that use params
  • The request object is immutable — use optional chaining (request?.headers) as it may be undefined in static mode
  • Return only JSON-serializable values (no Date, Map, Set, class instances, functions)
  • Use non-prefixed process.env vars for secrets in loaders, not EXPO_PUBLIC_ (which is embedded in the client bundle)
  • Use StatusError from expo-server for HTTP error responses
  • Use setResponseHeaders from expo-server to set headers
  • Export ErrorBoundary from route files to handle loader failures gracefully
  • Validate and sanitize user input (params, query strings) before using in database queries or API calls
  • Handle errors gracefully with try/catch; log server-side for debugging
  • Loader data is currently cached for the session. This is a known limitation that will be lifted in a future release