What is Remix?
Remix is a React framework built on web standards. Where many frameworks abstract away the platform, Remix leans into it: HTTP requests and responses, HTML forms, links, headers and cookies are the foundation, and the framework enhances them rather than replacing them.
The result is an application that works before JavaScript loads. Forms submit, links navigate and data appears, because the server is doing the work. When JavaScript does arrive, the same interactions become instant and partial. That property is called progressive enhancement, and it is Remix’s defining idea.
In 2024 the Remix team folded the framework into React Router v7, where it lives on as framework mode. The concepts and APIs are unchanged, so everything here applies to both.
Nested routes
Routes are files under app/routes, and dots in filenames map to URL segments.
app/routes/
├── _index.tsx # /
├── posts._index.tsx # /posts
├── posts.$slug.tsx # /posts/:slug
├── posts.$slug.edit.tsx # /posts/:slug/edit
└── api.health.tsx # /api/health
Routes nest to match the UI. A parent route renders shared layout and an <Outlet />, and child routes render inside it. Because Remix knows the whole route tree, it can load data for a page and all of its nested routes in parallel rather than in a waterfall.
// app/routes/posts.tsx
import { Outlet } from "@remix-run/react";
export default function PostsLayout() {
return (
<div className="posts">
<h1>Posts</h1>
<Outlet />
</div>
);
}
Loaders
A loader is a server function that provides data to a route before it renders. It runs on the server, so it can talk to a database directly.
// app/routes/posts.$slug.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug);
if (!post) throw json({ message: "Post not found" }, { status: 404 });
return json({ post });
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return <article>{post.title}</article>;
}
There is no loading state to manage and no client-side request. Remix calls the loader on the server for the initial render and again on navigation, and the component receives the data synchronously.
Actions and forms
Mutations are handled by actions, which are server functions tied to a route and triggered by form submissions.
// app/routes/posts.new.tsx
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
const data = await request.formData();
const title = String(data.get("title") ?? "").trim();
if (!title) return json({ error: "Title is required" }, { status: 400 });
await createPost(title);
return redirect("/posts");
}
export default function NewPost() {
return (
<Form method="post">
<input name="title" required />
<button type="submit">Create</button>
</Form>
);
}
Because this is a real form, it works without JavaScript. Remix intercepts the submission when the client is ready and revalidates the affected loaders automatically, so the UI reflects the new data without manual cache updates.
Error boundaries
Each route can export an ErrorBoundary that catches errors from its loader, action or component.
// app/routes/posts.$slug.tsx
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return <h1>Post not found</h1>;
}
return <h1>Something went wrong</h1>;
}
Boundaries are nested, so an error in a child route does not blank the entire page. The surrounding layout stays functional, which is a much better experience than a white screen.
Resource routes and web standards
A route with no default export is a resource route: it returns data rather than UI, which is ideal for APIs and webhooks.
// app/routes/api.health.tsx
export async function loader() {
return json({ status: "ok", time: Date.now() });
}
Because Remix uses the platform’s Request and Response, you can read headers, set cookies, stream responses and return any status code without learning a framework-specific API. That knowledge transfers directly to other runtimes.
Why the model feels different
Most React apps manage data in effects and keep it in client state, then sync that state back to the server through API routes. Remix collapses those layers:
- The route owns its data through a loader.
- The route owns its mutations through an action.
- The server owns the source of truth, and the client stays thin.
Fewer moving parts means fewer bugs, less state to synchronise and less code to write. The trade-off is a stronger opinion about how data flows, which is exactly why teams that adopt it tend to like it.
Best practices
- Fetch data in loaders, not in effects.
- Mutate through actions and forms, and let Remix revalidate.
- Use
FormandLinkfrom Remix so enhancement works automatically. - Export an
ErrorBoundaryon routes that can fail. - Keep nested routes aligned with the UI hierarchy.
- Use resource routes for APIs and webhooks.
- Return proper status codes and headers from loaders and actions.
Common mistakes
- Fetching in
useEffectinstead of a loader and losing server rendering. - Using
fetchfor mutations instead of an action and revalidating by hand. - Forgetting the
<Outlet />in a parent layout route. - Swallowing errors instead of letting boundaries handle them.
- Over-fetching in a parent loader when a child route should own the data.
- Treating Remix like a client-side SPA and fighting its server model.
Where to go next
Remix is the strongest expression of “use the platform” in the React world. Compare it with Next.js for the other major React approach, and with SvelteKit for a similar philosophy in another ecosystem. Strengthen your React, TypeScript and Node.js foundations, then build a small app with a loader, an action and an error boundary.