Loaders: data from the server
A loader is a function a route module exports under the name loader. The framework calls it on the server before rendering the page and passes whatever it returns to the component. No useEffect and no "loading" state: the page reaches the browser with the data already in it.
A data module
Until there is a database, posts live in an array. Create app/data.server.ts:
// app/data.server.ts
export type Post = { id: number; title: string; body: string };
const posts: Post[] = [
{ id: 1, title: "Hello, world", body: "The first blog post." },
{ id: 2, title: "React Router 7", body: "Building a blog with the framework." },
];
export async function getPosts() {
return posts;
}
export async function getPost(id: number) {
return posts.find((post) => post.id === id) ?? null;
}The .server suffix tells the framework this file is server-only. If you accidentally import it into code that ends up in the browser, the build stops with an error. The functions are async so that the array can later be replaced by a database without touching the pages.
The list of posts
Rewrite the home page app/routes/home.tsx:
// app/routes/home.tsx
import { Link } from "react-router";
import type { Route } from "./+types/home";
import { getPosts } from "../data.server";
export async function loader() {
const posts = await getPosts();
return { posts };
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<ul>
{loaderData.posts.map((post) => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}http://localhost:5173/ shows a list of two posts. The type of loaderData is inferred from what loader returned — the editor suggests post.title.
The post page
The loader receives an object with params and request (the HTTP request itself):
// app/routes/post.tsx
import type { Route } from "./+types/post";
import { getPost } from "../data.server";
export async function loader({ params }: Route.LoaderArgs) {
const post = await getPost(Number(params.postId));
if (!post) {
throw new Response("Post not found", { status: 404 });
}
return { post };
}
export default function Post({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}/posts/1 shows the first post. For /posts/99 the loader throws a response with status 404 — the error boundaries lesson shows how to present that page.
When the loader runs
| Situation | Where it runs |
|---|---|
| First visit to the page | On the server, before rendering HTML |
Navigating with Link | On the server, the browser gets only the data |
| After a form submission | On the server, data refreshes by itself |
Common mistake
Returning everything from a loader — for example, a whole user record including the password hash. Everything a loader returns is serialized and sent to the browser, where it is visible in the page source. Pick only the fields the component needs.