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:
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
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
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
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
// 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
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
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
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 });
};Using Cookie Helper
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
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
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
export const loader = async () => {
const posts = await getPosts();
return json(posts, {
headers: {
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'
}
});
};Conditional Requests
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
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
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
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
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
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
export const loader = async () => {
return json(
{ posts: await getPosts() },
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
}
}
);
};Streaming
Deferred Data
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
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
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
export const loader = async () => {
const [users, posts, comments] = await Promise.all([
getUsers(),
getPosts(),
getComments()
]);
return json({ users, posts, comments });
};Testing Loaders
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.