Skip to content
React rc ssr 4 min read

React with Remix

Remix is a full-stack React framework built around a simple bet: the web platform already solved most of the problems frameworks reinvent. Instead of layering a proprietary data layer over React, Remix leans on URLs, HTTP, forms, and fetch so your app keeps working even before — or without — JavaScript. As of v7, Remix has merged into React Router framework mode, meaning the routing library most React apps already use now ships loaders, actions, and SSR out of the box. The result is fast, server-rendered pages that progressively enhance into a snappy SPA.

Nested routes and the URL as state

In Remix, routes are nested both in the URL and in the UI. A route like /sales/invoices/123 maps to a chain of layout components, each owning its own slice of the screen and its own data. Parent layouts render an <Outlet /> where the matching child appears, so a sidebar, a list, and a detail pane can each load independently and persist across navigations.

// app/routes.ts — route config in React Router framework mode
import { type RouteConfig, route, index } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("invoices", "routes/invoices.tsx", [
    index("routes/invoices.index.tsx"),
    route(":id", "routes/invoices.detail.tsx"),
  ]),
] satisfies RouteConfig;

Each segment is its own module that can declare a loader (for reads) and an action (for writes), so data fetching is co-located with the UI that needs it.

Loaders: data on the server, before render

A loader runs on the server for the route being requested. Remix calls every matching route’s loader in parallel, waits for them, and renders the full HTML with data already in place — no client-side fetch waterfall, no loading spinners on first paint. The component reads that data with useLoaderData.

// app/routes/invoices.detail.tsx
import type { Route } from "./+types/invoices.detail";
import { useLoaderData } from "react-router";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(`https://api.example.com/invoices/${params.id}`);
  if (!res.ok) throw new Response("Not found", { status: 404 });
  return { invoice: await res.json() };
}

export default function InvoiceDetail() {
  const { invoice } = useLoaderData<typeof loader>();
  return (
    <article>
      <h2>Invoice #{invoice.number}</h2>
      <p>Total: ${invoice.total}</p>
    </article>
  );
}

Because loaders run only on the server, secrets and database clients never reach the bundle. Throwing a Response from a loader is caught by the nearest error boundary, giving you real HTTP status codes for free.

Actions and progressive enhancement

Mutations go through <Form> and an action. The <Form> component renders a plain HTML <form>; when JavaScript hasn’t loaded yet, the browser submits it the native way and Remix handles the POST on the server. Once hydrated, Remix intercepts the submit, posts via fetch, runs the action, and automatically re-runs the page’s loaders to refresh the UI.

// app/routes/invoices.detail.tsx (continued)
import { Form, redirect } from "react-router";

export async function action({ request, params }: Route.ActionArgs) {
  const form = await request.formData();
  await fetch(`https://api.example.com/invoices/${params.id}/pay`, {
    method: "POST",
    body: JSON.stringify({ note: form.get("note") }),
    headers: { "Content-Type": "application/json" },
  });
  return redirect(`/invoices/${params.id}`);
}

export function PayForm() {
  return (
    <Form method="post">
      <input name="note" placeholder="Payment note" />
      <button type="submit">Mark as paid</button>
    </Form>
  );
}

This is the heart of Remix’s philosophy: the app is functional with zero client JavaScript, then enhanced. Use useNavigation to show pending states and useFetcher for non-navigating mutations (like an inline “favorite” toggle) that don’t change the URL.

Tip: A submit button that works without JS is your baseline. Layer optimistic UI on top with useFetcher().formData — never make the happy path depend on hydration finishing first.

Remix vs Next.js

Both are excellent production frameworks, but they make different architectural bets. Next.js centers on React Server Components and a layered caching model; Remix centers on web standards (Request/Response, FormData, fetch) and a thinner abstraction you can reason about.

Remix (React Router)Next.js (App Router)
Core modelLoaders / actions + <Form>Server Components + Server Actions
Data fetchingParallel route loadersasync components, fetch cache
MutationsHTML form actionsServer Actions
RenderingSSR-first, optional prerenderRSC streaming, SSG, ISR
Mental modelWeb standards (Request/Response)React-centric, framework caching
Deploys anywhereYes (adapters for any runtime)Best on Vercel; portable with effort

Remix tends to win when you value progressive enhancement, runtime portability, and a small conceptual surface. Next.js tends to win when you want deep RSC integration, fine-grained caching, and the broadest ecosystem of hosted features.

Best Practices

  • Co-locate loader, action, and the component in one route module so data and UI evolve together.
  • Let loaders run in parallel — avoid await-ing one fetch before starting the next within a single route.
  • Always use <Form> and actions for mutations so the page works before JavaScript loads, then enhance.
  • Throw Response objects from loaders/actions to drive real HTTP status codes and error boundaries.
  • Reach for useFetcher when a mutation should not change the URL (toggles, inline edits, polling).
  • Show pending UI with useNavigation and optimistic UI with fetcher data to keep interactions snappy.
  • Keep secrets in loaders/actions only — anything in a default-exported component ships to the browser.
Last updated June 14, 2026
Was this helpful?