Skip to content

Loaders

Loaders fetch data on the server before rendering a route component. They are the primary way to get data into your pages.

Not sure which approach to use? See the Data Loading overview for a comparison of all three approaches (plain export, createLoader, defineRoute).

Import

ts
import {
  createLoader,
  defer,
  isDeferred,
  resolveDeferred,
  fetchData,
  FetchError,
  serializeLoaderData,
  parseLoaderData,
  combineLoaders,
  clientLoader,
} from '@ereo/data';

createLoader

Creates a type-safe loader function. Accepts either a plain function (shorthand) or an options object (with caching, transforms, and error handling).

Signature

ts
// Shorthand — pass a function directly
function createLoader<T, P extends RouteParams = RouteParams>(
  fn: (args: LoaderArgs<P>) => T | Promise<T>
): LoaderFunction<T, P>

// Full options — with caching, transforms, error handling
function createLoader<T, P extends RouteParams = RouteParams>(
  options: LoaderOptions<T, P>
): LoaderFunction<T, P>

LoaderOptions

ts
interface LoaderOptions<T, P extends RouteParams = RouteParams> {
  load: (args: LoaderArgs<P>) => T | Promise<T>;
  cache?: CacheOptions;
  transform?: (data: Awaited<T>, args: LoaderArgs<P>) => Awaited<T> | Promise<Awaited<T>>;
  onError?: (error: Error, args: LoaderArgs<P>) => T | Response | Promise<T | Response>;
}

LoaderArgs

ts
interface LoaderArgs<P = RouteParams> {
  request: Request;    // The incoming Request object
  params: P;           // URL parameters from dynamic segments
  context: AppContext;  // App context (cookies, headers, session, etc.)
}

Examples

Shorthand (Plain Function)

The simplest way to use createLoader — just pass an async function:

ts
export const loader = createLoader(async ({ params }) => {
  const post = await db.posts.findUnique({ where: { slug: params.slug } });
  if (!post) throw new Response('Not Found', { status: 404 });
  return { post };
});

This is equivalent to a plain function export but signals intent more clearly:

ts
// These two are equivalent:
export const loader = createLoader(async (args) => { ... })
export async function loader(args) { ... }

Options Object (With Features)

Use the options object when you need caching, transforms, or error handling:

ts
export const loader = createLoader({
  load: async ({ params }) => {
    return db.posts.findMany({ where: { authorId: params.userId } });
  },
  cache: {
    maxAge: 60,
    staleWhileRevalidate: 300,
    tags: ['posts'],
  },
});

With Transform

ts
export const loader = createLoader({
  load: async () => {
    return db.posts.findMany();
  },
  transform: (posts) => ({
    posts,
    count: posts.length,
  }),
});

With Error Handling

ts
export const loader = createLoader({
  load: async ({ params }) => {
    return externalApi.getUser(params.id);
  },
  onError: (error, { params }) => {
    console.error(`Failed to fetch user ${params.id}:`, error);
    return { user: null, error: 'Failed to load user' };
  },
});

Typed Loader

ts
interface PostData {
  post: Post;
  comments: Comment[];
}

interface PostParams {
  slug: string;
}

export const loader = createLoader<PostData, PostParams>({
  load: async ({ params }) => {
    const post = await db.posts.findUnique({ where: { slug: params.slug } });
    const comments = await db.comments.findMany({ where: { postId: post.id } });
    return { post, comments };
  },
});

Plain Function Export (Alternative)

You can also export a plain async function as loader. No imports needed:

ts
import type { LoaderArgs } from '@ereo/core'

export async function loader({ params }: LoaderArgs<{ id: string }>) {
  const post = await db.posts.find(params.id);
  if (!post) throw new Response('Not Found', { status: 404 });
  return { post };
}

This works because the EreoJS server calls whatever function is exported as loader. The createLoader helpers add features like caching and transforms on top.

defer

Defers data loading for streaming responses. Wrap a promise in defer() to stream the data to the client after the initial page render.

Signature

ts
function defer<T>(promise: Promise<T>): DeferredData<T>

Example

ts
export const loader = createLoader(async ({ params }) => {
  // Critical data — awaited before render
  const post = await db.posts.find(params.id)

  // Non-critical data — deferred and streamed later
  const comments = defer(db.comments.findByPost(params.id))
  const related = defer(db.posts.findRelated(params.id))

  return { post, comments, related }
})

In the component, use <Suspense> and <Await>:

tsx
import { Suspense } from 'react'
import { Await } from '@ereo/client'

export default function Post({ loaderData }) {
  return (
    <article>
      <h1>{loaderData.post.title}</h1>

      <Suspense fallback={<LoadingComments />}>
        <Await resolve={loaderData.comments}>
          {(comments) => <CommentList comments={comments} />}
        </Await>
      </Suspense>
    </article>
  )
}

isDeferred

Type guard to check if a value is deferred data.

ts
function isDeferred<T>(value: unknown): value is DeferredData<T>
ts
if (isDeferred(loaderData.comments)) {
  const resolved = await resolveDeferred(loaderData.comments)
} else {
  const comments = loaderData.comments
}

resolveDeferred

Resolves deferred data to its actual value.

ts
function resolveDeferred<T>(deferred: DeferredData<T>): Promise<T>

fetchData

Utility for fetching JSON data with error handling. Throws FetchError on non-OK responses.

ts
function fetchData<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T>
ts
export const loader = createLoader(async () => {
  const users = await fetchData<User[]>('https://api.example.com/users')
  return { users }
})

FetchError

Error thrown when fetchData receives a non-OK response:

ts
class FetchError extends Error {
  response: Response;
  get status(): number;
  get statusText(): string;
}

combineLoaders

Combines multiple loaders into one. All loaders run in parallel.

Signature

ts
function combineLoaders<T extends Record<string, LoaderFunction<unknown>>>(
  loaders: T
): LoaderFunction<{ [K in keyof T]: Awaited<ReturnType<T[K]>> }>

Example

ts
const userLoader = createLoader(async ({ request }) => {
  return { user: await getUser(request) }
})

const settingsLoader = createLoader(async () => {
  return { settings: await getSettings() }
})

// Both run in parallel. Returns { user: {...}, settings: {...} }
export const loader = combineLoaders({
  user: userLoader,
  settings: settingsLoader,
})

clientLoader

Creates a client-side loader that runs in the browser after hydration.

Signature

ts
function clientLoader<T, P extends RouteParams = RouteParams>(
  load: (params: P) => T | Promise<T>
): LoaderFunction<T, P>

Example

ts
// Server loader — initial data
export const loader = createLoader(async ({ params }) => {
  return { post: await db.posts.find(params.id) }
})

// Client loader — runs on client after hydration
export const clientLoader = clientLoader(async (params) => {
  const response = await fetch(`/api/posts/${params.id}`)
  return { post: await response.json() }
})

serializeLoaderData / parseLoaderData

Serializes and deserializes loader data for transport. serializeLoaderData escapes dangerous characters to prevent XSS.

ts
const serialized = serializeLoaderData(data)
const data = parseLoaderData<T>(serialized)

Response Handling

Loaders can throw Response objects for redirects and errors:

ts
import { redirect } from '@ereo/data'

export const loader = createLoader(async ({ request, params }) => {
  const user = await getUser(request)

  // Redirect if not authenticated
  if (!user) {
    throw redirect('/login')
  }

  // Throw 404
  const post = await db.posts.find(params.id)
  if (!post) {
    throw new Response('Not Found', { status: 404 })
  }

  return { post }
})

Best Practices

  1. Start with the shorthand — Use createLoader(fn) until you need options
  2. Use defer for slow data — Don't block rendering on non-critical content
  3. Handle errors explicitly — Throw Response objects with appropriate status codes
  4. Type your loaders — Use generics for type safety
  5. Consider caching — Use the options object form when data can be cached
  6. Keep loaders focused — One responsibility per loader, combine if needed

Released under the MIT License.