EmpireUI
Get Pro
← Blog9 min read#remix#loaders#actions

Remix Guide 2026: Loaders, Actions, Nested Routes and Error UI

Everything you actually need to know about Remix in 2026 — loaders, actions, nested routes, error boundaries, and building UI that doesn't fall apart under real conditions.

Developer coding a modern web application on dual monitors at night

Why Remix Still Matters in 2026

Remix hasn't had the loudest marketing in 2026, but it's quietly become the framework people reach for when they're tired of Next.js foot-guns. The mental model is tighter. The data layer is actually thought through. And once it clicks, you start seeing why so much of what we built with client-side state was unnecessary.

Honestly, the biggest thing Remix gives you isn't speed — it's clarity. You know where your data comes from. You know where your mutations go. The loader/action split is a constraint, and constraints make you better at your job. Compare that to juggling useEffect, SWR, a global store, and three layers of optimistic UI in a Next.js app.

That said, Remix has a learning curve. The v2 router (now stable) changed how a lot of things work, and the Vite-powered dev server they shipped in late 2025 means some older tutorials are outdated. This guide is current as of Remix v2.12.x and covers the real patterns you'll use day one.

Loaders: Server Data Without the Ceremony

A loader is just a function. It runs on the server — every time — before your route component renders. No hydration mismatch. No useEffect with an empty deps array. You export it from your route file and Remix handles the rest.

// app/routes/dashboard.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';

export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await getUserFromSession(request);
  if (!userId) throw redirect('/login');

  const stats = await db.user.findUnique({
    where: { id: userId },
    include: { projects: true },
  });

  return json({ stats });
}

export default function Dashboard() {
  const { stats } = useLoaderData<typeof loader>();
  return <pre>{JSON.stringify(stats, null, 2)}</pre>;
}

Worth noting: useLoaderData is fully typed when you do useLoaderData<typeof loader>(). No separate type definition needed. The inference flows from the function return, which is one of those small ergonomic wins you don't appreciate until you've been burned by mismatched types in other patterns.

Parallel data fetching is also built in. Each route in a nested tree runs its own loader simultaneously — Remix awaits them all before rendering. You're not waterfalling database calls, and you didn't have to write a single Promise.all. That's the kind of thing that should cost you three hours to figure out, but it's just the default.

One more thing — if you throw a Response or use the redirect helper inside a loader, Remix catches it and handles the redirect or error immediately. No try/catch in the component, no redirecting in useEffect. The server stays in control.

Actions: Mutations Done Right

Actions are the other half of the data layer. Where a loader handles GET, an action handles POST, PUT, PATCH, DELETE — whatever your form submits. And in Remix, forms submit to actions by default. No onSubmit, no fetch, no JSON body construction.

// app/routes/projects.new.tsx
import { redirect, json } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import type { ActionFunctionArgs } from '@remix-run/node';

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const name = String(formData.get('name'));

  if (name.length < 3) {
    return json({ error: 'Name must be at least 3 characters' }, { status: 400 });
  }

  const project = await db.project.create({ data: { name } });
  return redirect(`/projects/${project.id}`);
}

export default function NewProject() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <input name="name" type="text" />
      {actionData?.error && <p className="text-red-500">{actionData.error}</p>}
      <button type="submit">Create</button>
    </Form>
  );
}

In practice, this is the pattern that sells Remix to backend developers. It looks like a form. It works like a form. And it progressively enhances — if JS hasn't loaded yet, the form still submits and the action still runs. That's a browser-native behavior you get for free, not something you bolt on later.

Validation errors come back through useActionData. The component re-renders with the error, the form retains user input (if you wire up defaultValue to actionData), and you haven't touched a single state variable. It's a breath of fresh air after years of useState for every field.

Quick aside: actions only run on non-GET requests. If you submit a form with method="get", it updates the URL search params and triggers the loader, not the action. That's useful for filters and search UI — keep that pattern in your back pocket.

Nested Routes: The Layout System That Actually Works

Nested routes are where Remix earns its keep. The idea is that your URL structure maps to a component tree, and each level of that tree has its own loader, its own action, and its own error boundary. You're not lifting state to a global store because each route already owns its slice of the page.

app/routes/
  _layout.tsx          ← persistent shell (nav, sidebar)
  _layout.dashboard.tsx ← /dashboard route
  _layout.dashboard.projects.tsx ← /dashboard/projects
  _layout.dashboard.projects.$id.tsx ← /dashboard/projects/123
// app/routes/_layout.tsx
import { Outlet } from '@remix-run/react';
import { Sidebar } from '~/components/Sidebar';

export default function AppLayout() {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <main className="flex-1 overflow-auto p-8">
        <Outlet />
      </main>
    </div>
  );
}

The <Outlet /> is where child routes render. When you navigate from /dashboard/projects to /dashboard/projects/123, the layout doesn't remount. The sidebar doesn't flicker. Only the inner route updates. That's 20px of perceived performance for free, no animation library needed.

Look, this is the thing people don't realize until they've used it: nested routes eliminate an entire category of layout-related bugs. You're not syncing isOpen, activeTab, or selectedId between parent and child components across a global store. The URL is your state. Navigation is your mutation. It's obvious once you see it.

Error Boundaries and Error UI That Users Can Recover From

Every route can export an ErrorBoundary. If a loader throws, if an action throws, if the component itself throws — Remix renders the nearest ErrorBoundary instead. You don't need a top-level <ErrorBoundary> wrapping your whole app anymore. Each piece of the UI fails and recovers independently.

// app/routes/projects.$id.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <div className="p-8 text-center">
        <h1 className="text-2xl font-bold">{error.status} {error.statusText}</h1>
        <p className="mt-2 text-gray-500">{error.data}</p>
        <a href="/projects" className="mt-4 inline-block underline">Back to projects</a>
      </div>
    );
  }

  return (
    <div className="p-8">
      <h1 className="text-xl font-bold">Something went wrong</h1>
      <p>{error instanceof Error ? error.message : 'Unknown error'}</p>
    </div>
  );
}

The isRouteErrorResponse check matters. When you throw json({ message: 'Not found' }, { status: 404 }) in a loader, it comes back as a RouteErrorResponse. Regular JavaScript errors (null pointer, type error, whatever) are plain Error instances. Handle both and your error UI covers every real case.

Worth noting: styling these error states is where your design system pays off. Empire UI's glassmorphism components look especially good for full-page error states — the blurred card on a muted background signals "something's off" without being alarming. You can also pair it with the box shadow generator to give the error card some depth without going overboard.

One pattern I keep coming back to: throw a 404 from the loader when an entity doesn't exist, and let the ErrorBoundary handle it. Don't return null and render a blank page. The boundary is there — use it. Users get a recoverable error with a back link, not a mystery empty screen.

Pending UI and Optimistic Updates

Remix has useNavigation and useFetcher built in. useNavigation tells you the global navigation state — is a form submitting, is the page transitioning. useFetcher lets you run loaders and actions without navigating away. Both are synchronous and trivial to use.

import { useNavigation } from '@remix-run/react';

export default function NewProject() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post">
      <input name="name" type="text" />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Project'}
      </button>
    </Form>
  );
}

For optimistic UI — showing the new item before the server confirms — reach for useFetcher. You can read fetcher.formData while the request is in flight and render the speculative result immediately. The official Remix docs have a good example with a like button, but the same pattern works for any low-stakes mutation where latency would feel jarring.

function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
  const fetcher = useFetcher();
  const optimisticLiked = fetcher.formData
    ? fetcher.formData.get('liked') === 'true'
    : liked;

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <input type="hidden" name="liked" value={String(!optimisticLiked)} />
      <button type="submit">
        {optimisticLiked ? 'Unlike' : 'Like'}
      </button>
    </fetcher.Form>
  );
}

Honestly, this is the code you'd write in 2022 with a custom hook, three useState calls, and a useEffect for the abort controller. In Remix it's 15 lines. That's not magic — it's just the framework doing the boring parts for you.

Putting It Together: Remix + UI That Doesn't Look Like a Demo

Data layer sorted, now the part that often gets neglected: the actual UI. Remix gives you clean data and mutation patterns, but you still have to build components that look and feel right. That's where a library like Empire UI saves you the afternoon.

For dashboards built on Remix — the most common use case by far — you want components that pair well with the loader pattern. Cards that accept data props directly, tables that render arrays, stat components that work with whatever shape your loader returns. No internal fetch, no SWR hook competing with your loader. Just props in, UI out.

If you're building anything with a premium aesthetic in 2026, worth checking out the glassmorphism generator for your card backdrops, and the gradient generator for hero sections and empty states. These tools spit out production-ready CSS you drop straight into your Tailwind classes — no design handoff needed.

Error states deserve as much design attention as happy paths. A good ErrorBoundary component isn't just an h1 and a message — it's a card with appropriate visual weight, a clear action (retry, go back, contact support), and styling that fits the rest of your app. Browse the Empire UI component library for patterns you can adapt; the empty state and error page components are particularly good starting points.

One more thing — Remix and progressive enhancement means your UI should work before JavaScript loads. Style your forms so the native submit state looks acceptable. Don't hide your submit button behind JS-only onClick. When you do that, the Remix mental model clicks into place: server first, enhancement second, flashy animations last.

FAQ

What's the difference between a loader and an action in Remix?

Loaders handle GET requests and provide data to your component before it renders. Actions handle form submissions (POST, PUT, DELETE) and run mutations. They're in the same route file but serve entirely different purposes.

Can I use Remix with Tailwind CSS and a component library?

Yes, completely compatible. Remix has no opinion about your CSS setup — install Tailwind via the PostCSS plugin just like any Vite project. Third-party component libraries drop in with no changes needed.

How do nested routes affect performance?

Positively. Remix fetches all nested route loaders in parallel and only re-renders the routes that changed on navigation. You get fewer waterfalls and zero layout flicker out of the box.

Do I still need React Query or SWR with Remix?

Rarely. Loaders cover most read cases, and useFetcher handles background fetches without navigation. You might reach for React Query for highly dynamic polling intervals, but most apps don't need it.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

SvelteKit Guide 2026: Routing, Load Functions, Forms and DeployNuxt 3 Guide 2026: SSR, Composables, Nitro and Auto-ImportsRemix vs Next.js in 2026: Loaders vs Server Actions, Nested vs LayoutsNext.js vs Remix in 2026: Which One Should You Use?