Skip to main content
Technology & EngineeringRemix189 lines

Loaders

Server-side data loading with loader functions, useLoaderData, and type-safe data fetching in Remix

Quick Summary17 lines
You are an expert in Remix data loading patterns, including loader functions, `useLoaderData`, type inference, request handling, and cache strategies.

## Key Points

- Always validate and sanitize params — they come from the URL and are user-controlled.
- Throw `Response` objects (not `Error`) for expected failures (404, 403) so error boundaries can render appropriate UI.
- Keep loaders focused: one loader per route, fetching only what that route segment needs.
- Use `json()` helper to set proper `Content-Type` headers and enable type inference.
- Set `Cache-Control` headers on responses for data that changes infrequently.
- Use `request.signal` to pass the abort signal to database queries or fetch calls for proper cancellation.
- Returning raw objects without `json()` works at runtime but loses headers and can cause type-inference issues.
- Accessing `process.env` is fine in loaders (server-only) but never in components (client-side).
- Forgetting that loaders re-run after every action — expensive queries without caching will slow down mutations.
- Using `redirect()` inside a try/catch — `redirect()` throws a Response, so a catch block will swallow it. Either re-throw responses or check `instanceof Response`.
- Not handling the case where `params.id` is `undefined` — TypeScript types params values as `string | undefined`.
skilldb get remix-skills/LoadersFull skill: 189 lines
Paste into your CLAUDE.md or agent config

Loaders — Remix

You are an expert in Remix data loading patterns, including loader functions, useLoaderData, type inference, request handling, and cache strategies.

Overview

In Remix, every route can export a loader function that runs on the server before the route renders. The loader receives a LoaderFunctionArgs object containing the request, params, and context. Data returned from the loader is available in the component via the useLoaderData hook. Loaders run in parallel for nested routes, making page loads efficient.

Core Concepts

Basic Loader

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader({ request, params }: LoaderFunctionArgs) {
  const product = await db.product.findUnique({
    where: { id: params.id },
  });
  if (!product) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ product });
}

export default function Product() {
  const { product } = useLoaderData<typeof loader>();
  return <h1>{product.name}</h1>;
}

Type Safety with typeof loader

Using useLoaderData<typeof loader>() provides automatic type inference. The generic parameter serializes the return type so dates become strings and other non-serializable values are correctly typed.

Accessing Request Information

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const query = url.searchParams.get("q") ?? "";
  const page = Number(url.searchParams.get("page") ?? "1");

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

Accessing Route Params

// Route: app/routes/teams.$teamId.members.$memberId.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const { teamId, memberId } = params;
  const member = await getTeamMember(teamId!, memberId!);
  return json({ member });
}

Implementation Patterns

Parallel Data Loading in Nested Routes

Remix calls all loaders for matched routes in parallel. A parent and child loader run simultaneously:

// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  return json({ user });
}

// app/routes/dashboard.projects.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  const projects = await getProjects(user.id);
  return json({ projects });
}

Both loaders execute at the same time when the user navigates to /dashboard/projects.

Throwing Responses for Non-Happy Paths

export async function loader({ params }: LoaderFunctionArgs) {
  const invoice = await getInvoice(params.id!);
  if (!invoice) {
    throw new Response("Invoice not found", { status: 404 });
  }
  if (!invoice.isPublic) {
    throw new Response("Forbidden", { status: 403 });
  }
  return json({ invoice });
}

Returning Headers and Cache Control

export async function loader({ params }: LoaderFunctionArgs) {
  const data = await getCachedData(params.slug!);
  return json(data, {
    headers: {
      "Cache-Control": "public, max-age=300, s-maxage=3600",
    },
  });
}

Using Fetch Inside Loaders

export async function loader({ request }: LoaderFunctionArgs) {
  const apiKey = process.env.API_KEY;
  const res = await fetch("https://api.example.com/data", {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!res.ok) {
    throw new Response("Upstream API error", { status: 502 });
  }
  const data = await res.json();
  return json(data);
}

Revalidation

Remix automatically revalidates all loaders on the page after any action completes. You can control which loaders revalidate with shouldRevalidate:

export function shouldRevalidate({
  actionResult,
  currentUrl,
  nextUrl,
  defaultShouldRevalidate,
}: ShouldRevalidateFunctionArgs) {
  // Skip revalidation if only the search params changed
  if (currentUrl.pathname === nextUrl.pathname) {
    return false;
  }
  return defaultShouldRevalidate;
}

Core Philosophy

Loaders in Remix represent a clear contract: data fetching happens on the server, before the component renders. There is no useEffect for initial data, no loading spinners for the primary content, and no client-server waterfall. The page arrives with its data already embedded, which is faster for the user and simpler for the developer. This server-first data model is Remix's most opinionated design decision and the source of most of its benefits.

Nested route loaders run in parallel, which is a deliberate architectural advantage. When a user navigates to /dashboard/projects, the dashboard layout loader and the projects loader execute simultaneously on the server. This parallelism means the time to render a nested page is the duration of the slowest loader, not the sum of all loaders. You get the organizational benefit of co-located data fetching without the performance penalty of serial requests.

The typeof loader generic on useLoaderData creates a type-safe bridge between server and client code. The return type of the loader function flows through serialization (Dates become strings, non-serializable values are stripped) and arrives in the component with accurate types. This eliminates the common category of bugs where the component assumes a shape that differs from what the server actually returns.

Anti-Patterns

  • Fetching data in useEffect instead of the loader. Client-side data fetching creates waterfalls, shows loading spinners, and breaks server-rendering. If the data is needed for the page, fetch it in the loader so it arrives with the initial HTML.

  • Returning raw objects without json(). While technically functional, omitting json() loses the ability to set response headers (like Cache-Control) and can cause type inference issues with useLoaderData<typeof loader>().

  • Performing expensive operations without caching. Loaders re-run after every action on the page. If a loader makes a slow database query or API call, every form submission triggers that cost. Use response caching, shouldRevalidate, or database-level caching to mitigate.

  • Accessing process.env in components instead of loaders. Environment variables are available server-side in loaders but not in client-side components. Pass needed values from the loader to the component through the return data.

  • Forgetting that params values are string | undefined. Route parameters come from the URL and are always strings or undefined. Passing them to functions that expect numbers or non-nullable types without validation causes subtle runtime bugs.

Best Practices

  • Always validate and sanitize params — they come from the URL and are user-controlled.
  • Throw Response objects (not Error) for expected failures (404, 403) so error boundaries can render appropriate UI.
  • Keep loaders focused: one loader per route, fetching only what that route segment needs.
  • Use json() helper to set proper Content-Type headers and enable type inference.
  • Set Cache-Control headers on responses for data that changes infrequently.
  • Use request.signal to pass the abort signal to database queries or fetch calls for proper cancellation.

Common Pitfalls

  • Returning raw objects without json() works at runtime but loses headers and can cause type-inference issues.
  • Accessing process.env is fine in loaders (server-only) but never in components (client-side).
  • Forgetting that loaders re-run after every action — expensive queries without caching will slow down mutations.
  • Using redirect() inside a try/catch — redirect() throws a Response, so a catch block will swallow it. Either re-throw responses or check instanceof Response.
  • Not handling the case where params.id is undefined — TypeScript types params values as string | undefined.

Install this skill directly: skilldb add remix-skills

Get CLI access →