Remix
01 / 02

Routes, Loaders & Actions

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