Remix: Routes, Loaders & Actions
Remix is a full-stack React framework built around web fundamentals — nested routes, HTTP forms/caching — rather than reinventing them. As of React Router v7, Remix's framework features (loaders, actions, nested routing) live inside React Router itself.
File-Based Routing & Loaders
// app/routes/projects.$projectId.tsx -> URL: /projects/:projectId
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// Server-only — never bundled to the client
export async function loader({ params }: LoaderFunctionArgs) {
const project = await getProject(params.projectId!);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
return json(project);
}
export default function ProjectRoute() {
const project = useLoaderData<typeof loader>();
return <h1>{project.name}</h1>;
}
// meta derives SEO tags from loader data
export function meta({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
return [{ title: data.name }, { name: "description", content: data.summary }];
}
// Errors thrown in this route's loader/component render THIS instead
export function ErrorBoundary() {
return <p>Could not load this project.</p>;
}Actions & Forms
import { Form, redirect, type ActionFunctionArgs } from "@remix-run/node";
// Handles a POST to this route -- the target for a <Form> submission
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
if (typeof title !== "string" || title.length === 0) {
return json({ error: "Title is required" }, { status: 400 });
}
await updateProject(params.projectId!, { title });
// After an action resolves, Remix automatically re-runs every active
// route's loader -- no manual cache invalidation needed
return redirect(`/projects/${params.projectId}`);
}
export default function EditProject() {
const actionData = useActionData<typeof action>();
return (
// Progressive enhancement: works as a plain HTML form POST even
// without JS; with JS, Remix intercepts for a client-side transition
<Form method="post">
<input name="title" defaultValue={project.title} />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Save</button>
</Form>
);
}Nested Routes & Outlets
app/routes/projects.tsx -- layout for /projects/*, renders <Outlet /> for the matched child.
app/routes/projects._index.tsx -- matches exactly /projects (the index route).
app/routes/projects.$projectId.tsx -- matches /projects/:projectId, rendered inside the parent's <Outlet />.
Navigating between sibling routes only re-runs the changed segment's loader -- the parent layout's data/UI stays put.
$ prefixes a dynamic segment (params.projectId); dots in filenames represent nested path segments.
Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free