Skip to content
All essays
CraftJanuary 15, 202612 min

Remix Loaders: Server-Side Data Loading Made Simple

Master data loading in Remix with loaders, caching, error handling, and best practices for server-side data fetching

Ü
Ümit Uz
Mobile & Full Stack Developer

Loaders are the heart of data fetching in Remix. They run on the server, provide data to your components, and handle all the complexities of server-side rendering. This comprehensive guide covers everything you need to know about loaders.

What is a Loader?

A loader is an async function that runs on the server to provide data to your route component:

tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export const loader = async () => {
  const data = await fetch('https://api.example.com/data');
  return json(data);
};

export default function Route() {
  const data = useLoaderData<typeof loader>();
  return <div>{data.title}</div>;
}

Basic Loader Patterns

Fetching Data

tsx
import { json } from '@remix-run/node';

export const loader = async () => {
  const response = await fetch('https://api.example.com/posts');

  if (!response.ok) {
    throw new Response('Failed to fetch posts', { status: response.status });
  }

  const posts = await response.json();
  return json(posts);
};

Querying a Database

tsx
import { json } from '@remix-run/node';
import { db } from '~/utils/db.server';

export const loader = async () => {
  const users = await db.user.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  });

  return json(users);
};

Using Request Headers

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const authHeader = request.headers.get('Authorization');

  if (!authHeader) {
    throw new Response('Unauthorized', { status: 401 });
  }

  const user = await getUserFromToken(authHeader);
  return json({ user });
};

URL Parameters and Search Params

Route Parameters

tsx
// app/routes/posts.$postId.tsx
export const loader = async ({ params }: LoaderFunctionArgs) => {
  const post = await db.post.findUnique({
    where: { id: params.postId }
  });

  if (!post) {
    throw new Response('Not Found', { status: 404 });
  }

  return json(post);
};

Query Parameters

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const search = url.searchParams.get('q');
  const page = Number(url.searchParams.get('page') || '1');
  const limit = Number(url.searchParams.get('limit') || '10');

  const posts = await searchPosts({ search, page, limit });
  return json(posts);
};

Combined Parameters

tsx
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const userId = params.userId;
  const filter = url.searchParams.get('filter');

  const posts = await getUserPosts(userId, filter);
  return json(posts);
};

Working with Cookies

Reading Cookies

tsx
import { json } from '@remix-run/node';

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const cookieHeader = request.headers.get('Cookie');
  const cookies = Object.fromEntries(
    cookieHeader?.split('; ').map(c => c.split('=')) || []
  );

  const sessionId = cookies.session_id;

  if (!sessionId) {
    throw redirect('/login');
  }

  const session = await getSession(sessionId);
  return json({ user: session.user });
};
tsx
import { createCookieSessionStorage } from '@remix-run/node';

const { getSession } = createCookieSessionStorage({
  cookie: {
    name: 'session',
    secrets: [process.env.SESSION_SECRET],
  },
});

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const session = await getSession(request.headers.get('Cookie'));

  if (!session.has('userId')) {
    throw redirect('/login');
  }

  const userId = session.get('userId');
  const user = await db.user.findUnique({ where: { id: userId } });

  return json({ user });
};

Error Handling

Throwing Responses

tsx
export const loader = async ({ params }: LoaderFunctionArgs) => {
  const post = await getPost(params.postId);

  if (!post) {
    throw new Response('Post not found', { status: 404 });
  }

  return json(post);
};

Custom Error Pages

tsx
export const loader = async () => {
  try {
    const data = await fetchData();
    return json(data);
  } catch (error) {
    if (error instanceof NotFoundError) {
      throw new Response('Resource not found', { status: 404 });
    }
    throw new Response('Internal server error', { status: 500 });
  }
};

export function ErrorBoundary() {
  const error = useRouteError();

  if (error instanceof Response) {
    return <div>Error: {error.statusText}</div>;
  }

  return <div>Something went wrong</div>;
}

Caching

HTTP Caching

tsx
export const loader = async () => {
  const posts = await getPosts();

  return json(posts, {
    headers: {
      'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'
    }
  });
};

Conditional Requests

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const etag = 'v1';

  if (request.headers.get('If-None-Match') === etag) {
    return new Response(null, { status: 304 });
  }

  const data = await fetchData();

  return json(data, {
    headers: {
      'ETag': etag,
      'Cache-Control': 'public, max-age=3600'
    }
  });
};

Authentication and Authorization

Protected Routes

tsx
import { redirect } from '@remix-run/node';

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const session = await getUserSession(request);

  if (!session) {
    return redirect('/login');
  }

  const user = await db.user.findUnique({
    where: { id: session.userId }
  });

  return json({ user });
};

Role-Based Access

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const session = await getUserSession(request);

  if (!session?.roles.includes('admin')) {
    throw new Response('Forbidden', { status: 403 });
  }

  const adminData = await getAdminData();
  return json(adminData);
};

Pagination

Basic Pagination

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get('page') || '1');
  const limit = 20;
  const skip = (page - 1) * limit;

  const [posts, total] = await Promise.all([
    db.post.findMany({ skip, take: limit }),
    db.post.count()
  ]);

  const totalPages = Math.ceil(total / limit);

  return json({
    posts,
    pagination: {
      page,
      totalPages,
      total,
      hasNext: page < totalPages,
      hasPrev: page > 1
    }
  });
};

Cursor-Based Pagination

tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const cursor = url.searchParams.get('cursor');

  const posts = await db.post.findMany({
    take: 20,
    cursor: cursor ? { id: cursor } : undefined,
    orderBy: { createdAt: 'desc' }
  });

  return json({
    posts,
    nextCursor: posts.length === 20 ? posts[19].id : null
  });
};

Real-Time Data

Using Remix's Revalidation

tsx
import { useRevalidator } from '@remix-run/react';

export default function Dashboard() {
  const { revalidate } = useRevalidator();

  useEffect(() => {
    const interval = setInterval(() => {
      revalidate(); // Re-run loader
    }, 5000);

    return () => clearInterval(interval);
  }, [revalidate]);

  return <div>Dashboard</div>;
}

With EventSource

tsx
export const loader = async () => {
  return json(
    { posts: await getPosts() },
    {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
      }
    }
  );
};

Streaming

Deferred Data

tsx
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';

export const loader = async () => {
  const quickData = await getQuickData();
  const slowData = getSlowData(); // Returns a promise

  return defer({
    quickData,
    slowData
  });
};

export default function Route() {
  const { quickData, slowData } = useLoaderData<typeof loader>();

  return (
    <div>
      <div>{quickData.title}</div>

      <Suspense fallback={<p>Loading...</p>}>
        <Await resolve={slowData}>
          {(data) => <div>{data.content}</div>}
        </Await>
      </Suspense>
    </div>
  );
}

Best Practices

1. Type Safety

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

async function getPosts() {
  return db.post.findMany();
}

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const posts = await getPosts();
  return json(posts);
};

export default function Posts() {
  const posts = useLoaderData<typeof loader>(); // Fully typed
  return <div>{posts.length} posts</div>;
}

2. Error Boundaries

tsx
export function ErrorBoundary() {
  const error = useRouteError();

  if (error instanceof Response && error.status === 404) {
    return <div>Not Found</div>;
  }

  return <div>An error occurred</div>;
}

3. Parallel Loading

tsx
export const loader = async () => {
  const [users, posts, comments] = await Promise.all([
    getUsers(),
    getPosts(),
    getComments()
  ]);

  return json({ users, posts, comments });
};

Testing Loaders

tsx
import { loader } from './route';

test('loader returns posts', async () => {
  const request = new Request('http://localhost:3000/posts');
  const response = await loader({ request, params: {}, context: {} });

  const data = await response.json();
  expect(data).toHaveProperty('posts');
});

Conclusion

Loaders are the backbone of data fetching in Remix. They run on the server, provide type-safe data to your components, and handle all the complexity of data loading. By mastering loaders, you can build fast, type-safe applications with excellent user experiences.

Remember: loaders run on the server, so keep them focused on data fetching and business logic. Keep your components simple and focused on presentation. This separation leads to cleaner, more maintainable code.

Enjoyed this essay? Share it.

Related essays

Next essay
Remix Actions: Handling Form Submissions and Mutations