Data loaders

Edit page

Learn how to fetch data on the server using data loaders in Expo Router.


Data loaders are in alpha and are available in SDK 55 and later. They require either static rendering or server rendering.

Data loaders enable server-side data fetching for your routes. By exporting a loader function from a route file, you can fetch data on the server and access it in your component using the useLoaderData hook. This lets you keep sensitive data and API keys on the server while providing your components with the data they need.

Setup

1

Enable data loaders in your project's app config by adding the unstable_useServerDataLoaders option to the expo-router plugin:

app.json
{ "expo": { %%placeholder-start%%... %%placeholder-end%% "plugins": [ [ "expo-router", { "unstable_useServerDataLoaders": true, "unstable_useServerRendering": true } ] ] } }

2

Configure your web output mode. Data loaders work with both static rendering (web.output: 'static') and server rendering (web.output: 'server'):

app.json
{ "expo": { %%placeholder-start%%... %%placeholder-end%% "web": { %%placeholder-start%%... %%placeholder-end%% "output": "server" } } }

3

Start the development server:

Terminal
- npx expo start

Basic example

Export a loader function from your route file and use the useLoaderData hook to access the data in your component:

app/index.tsx
import { Text, View } from 'react-native'; import { useLoaderData } from 'expo-router'; export async function loader() { // Fetch data from an API, database, or any server-side source const response = await fetch('https://api.example.com/data'); return response.json(); } export default function Home() { const data = useLoaderData<typeof loader>(); return ( <View> <Text>Data: {JSON.stringify(data)}</Text> </View> ); }

The loader function executes on the server, and its return value is serialized and passed to your component. This means you can safely use server-side secrets, database connections, and other resources that should not be exposed to the client. When using TypeScript, passing typeof loader as the generic parameter to useLoaderData allows the hook to infer the return type from your loader function.

The useLoaderData hook does not need to be called in the route component itself. It can be called in any child component within the route's component tree.

Using Suspense

When a component calls the useLoaderData hook while data is still loading, React suspends that component. The loading state cascades up the component tree until it reaches the nearest <Suspense> boundary, which then renders its fallback.

This lets you control exactly where loading fallbacks appear by placing <Suspense> boundaries in your component tree:

app/index.tsx
import { Suspense } from 'react'; import { Text, View } from 'react-native'; import { useLoaderData } from 'expo-router'; export async function loader() { const response = await fetch('https://api.example.com/data'); return response.json(); } export default function Home() { return ( <View> <Text>Welcome</Text> <Suspense fallback={<Text>Loading...</Text>}> <DataSection /> </Suspense> </View> ); } function DataSection() { const data = useLoaderData<typeof loader>(); return <Text>{data.title}</Text>; }

In the above example, useLoaderData is in a child component of <Home> and wrapped it with <Suspense> to show a loading state.

Error handling

When a loader throws an error, it propagates to the nearest error boundary. You can export an ErrorBoundary component from the same route file to handle loader errors:

app/data.tsx
import { Text, View } from 'react-native'; import { useLoaderData, type ErrorBoundaryProps } from 'expo-router'; export async function loader() { const response = await fetch('https://api.example.com/data'); if (!response.ok) { throw new Error('Failed to fetch data'); } return response.json(); } export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { return ( <View> <Text>Error: {error.message}</Text> <Text onPress={retry}>Try again</Text> </View> ); } export default function DataPage() { const data = useLoaderData<typeof loader>(); return ( <View> <Text>{data.title}</Text> </View> ); }

When no ErrorBoundary is exported, the error propagates to the nearest parent route's error boundary. You can also use custom error boundary components within your route to catch errors at specific points in the component tree.

Dynamic routes

Loaders receive route parameters as the second argument:

app/posts/[postId].tsx
import { Text, View } from 'react-native'; import { useLoaderData } from 'expo-router'; export async function loader(request, params) { const response = await fetch(`https://api.example.com/posts/${params.postId}`); return response.json(); } export default function Post() { const data = useLoaderData<typeof loader>(); return ( <View> <Text>{data.title}</Text> <Text>{data.content}</Text> </View> ); }

Accessing the request

The request parameter is undefined when using static rendering because there is no HTTP request at build-time.

When using server rendering, loaders receive the incoming HTTP request as the first argument. This allows you to access headers, cookies, and other request information:

app/profile.tsx
import { Text, View } from 'react-native'; import { useLoaderData } from 'expo-router'; export async function loader(request) { // Access authorization header const authToken = request?.headers.get('Authorization'); if (!authToken) { return { user: null }; } // Fetch user data using the token const response = await fetch('https://api.example.com/user', { headers: { Authorization: authToken }, }); return { user: await response.json() }; } export default function Profile() { const { user } = useLoaderData<typeof loader>(); if (!user) { return <Text>Please log in</Text>; } return ( <View> <Text>Welcome, {user.name}</Text> </View> ); }

Returning data

Loaders can return data as plain JSON, which is easily deserialized using JSON.parse. This includes objects, arrays or any other primitive that can be serialized with JSON.stringify.

app/index.tsx
export async function loader() { const response = await fetch('https://api.example.com/data'); return response.json(); }

If your loader returns undefined or null, the value is normalized to null.

Runtime API

Data loaders have full access to the Runtime API from expo-server. This includes utilities for setting response headers, throwing HTTP errors, and running background tasks:

app/example.tsx
import { setResponseHeaders, StatusError } from 'expo-server'; export async function loader(request) { const authToken = request?.headers.get('Authorization'); if (!authToken) { throw new StatusError(401, 'Unauthorized'); } setResponseHeaders({ 'Cache-Control': 'private, max-age=60' }); return { user: 'authenticated' }; }

See the Runtime API documentation for a full list of available functions.

Environment variables

Loaders run on the server and have access to process.env. Environment variables used in loaders are never exposed to the client bundle. This is useful for accessing API keys and other secrets:

app/api-data.tsx
import { Text, View } from 'react-native'; import { useLoaderData } from 'expo-router'; export async function loader() { const apiKey = process.env.API_SECRET_KEY; const response = await fetch('https://api.example.com/data', { headers: { 'X-API-Key': apiKey }, }); return response.json(); } export default function ApiData() { const data = useLoaderData<typeof loader>(); return ( <View> <Text>{JSON.stringify(data)}</Text> </View> ); }

Difference between static and server rendering

Data loaders behave differently depending on your web.output configuration:

AspectStatic RenderingServer Rendering
Loader executionBuild timeRequest time
request parameterundefinedImmutableRequest
Best forBlogs, marketing pages, documentationPersonalized content, authentication-dependent pages

Static rendering

With static rendering, loaders execute during when exporting your app with npx expo export. The data is embedded in the generated HTML and JSON files. This means:

  • Data is determined at build-time and does not change until the next build
  • The request parameter is undefined because there is no HTTP request during the build
  • Ideal for content that does not change frequently

Server rendering

With server rendering, loaders execute on every request. This means:

  • The request parameter contains an immutable version of the incoming HTTP request
  • Requires expo-server for production deployment

Typed loader functions

For improved type safety, you can import the LoaderFunction type from expo-router:

app/posts/[postId].tsx
import { Text, View } from 'react-native'; import { useLoaderData } from 'expo-router'; import { type LoaderFunction } from 'expo-router/server'; type PostData = { title: string; content: string; }; export const loader: LoaderFunction<PostData> = async (request, params) => { const response = await fetch(`https://api.example.com/posts/${params.postId}`); return response.json(); }; export default function Post() { const data = useLoaderData<typeof loader>(); return ( <View> <Text>{data.title}</Text> <Text>{data.content}</Text> </View> ); }

Known limitations

  • Loaders must return JSON-serializable data. Streaming responses are currently not supported. This will be addressed in a future release.
  • Loader data is cached on the client during navigation. There is currently no built-in way to invalidate this cache. This will be addressed in a future release.

Common questions

Can I use data loaders without server rendering?

Yes. Data loaders work with both static rendering (web.output: 'static') and server rendering (web.output: 'server').

Are loaders included in the client bundle?

No, loader exports are dropped from the client bundle. However, if another module contains server-side logic and is imported by client-side code outside of the app directory, it may be included in your client-side bundle.