Frontend

Remix: Building Full-Stack React Apps

Mayur Dabhi
Mayur Dabhi
June 8, 2026
14 min read

You open a React dashboard and watch it load in stages. The layout renders, then a spinner, then the user fetch finishes and kicks off the stats fetch, which finishes and kicks off the notifications fetch. Every component has its own useEffect, its own loading flag, and its own copy of server data that goes stale the moment someone submits a form. Most of that code exists only to move data from the server into the UI and keep it in sync.

Remix, from the team behind React Router, attacks that problem by moving data loading and mutations back to the server, per route, using plain HTTP and HTML forms. This post covers setup, file-based routing, loaders, actions, nested layouts, error boundaries and meta tags, then compares Remix with Next.js and ends with where I think it fits and where it doesn't.

Some Background

Shopify acquired Remix in 2022, and its Hydrogen storefront framework is built on it. Remix runs on several JavaScript runtimes through adapters: Node.js, Deno, Cloudflare Workers and AWS Lambda, among others.

How Remix Is Put Together

Under the hood, Remix is a compiler plus a server runtime wrapped around React Router. A Remix app is basically a React Router app with a server layer built in, so the way you think about client-side routes also maps onto how Remix handles server requests.

Almost everything you write in Remix falls into one of three pieces:

Browser GET /dashboard POST /form Remix Server Loader Action SSR + Hydrate Database Prisma / SQL External API REST / GraphQL React Component useLoaderData() Remix Request / Response Lifecycle

Remix routes data from server loaders directly into React components, with no client-side fetching

Installation and Setup

The official create-remix CLI scaffolds a starter project for you, so setup only takes a couple of commands.

1

Create a New Remix App

Run the create-remix CLI to scaffold your project. You'll be prompted to choose a deployment target (Node.js, Cloudflare, Vercel, etc.).

Terminal
# Create a new Remix project
npx create-remix@latest my-remix-app

# Navigate into the project
cd my-remix-app

# Install dependencies (if not already done)
npm install

# Start the development server
npm run dev
2

Project Structure Overview

Remix follows a app/ directory convention. Routes live in app/routes/ and the root layout is in app/root.tsx.

Project Structure
my-remix-app/
├── app/
│   ├── root.tsx          # Root layout (html, head, body)
│   ├── entry.client.tsx  # Client-side hydration entry
│   ├── entry.server.tsx  # Server-side rendering entry
│   └── routes/
│       ├── _index.tsx    # Matches /
│       ├── about.tsx     # Matches /about
│       ├── blog._index.tsx    # Matches /blog
│       └── blog.$slug.tsx     # Matches /blog/:slug
├── public/               # Static assets
├── remix.config.js       # Remix configuration
└── package.json
3

Configure Your Root Layout

The app/root.tsx file wraps your entire application. It renders the <html>, <head>, and <body> tags, so you control the whole document.

app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />    {/* Renders meta tags from route loaders */}
        <Links />  {/* Renders link tags (CSS, etc.) */}
      </head>
      <body>
        <Outlet />          {/* Renders the matched route */}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

File-Based Routing in Remix

Routes in Remix v2 are flat files. Next.js nests folders to nest routes; Remix puts the nesting in the filename and uses dots as separators. You can see the whole route tree in one directory listing, though it gets long in bigger apps.

Route File Naming Convention

File Name URL Pattern Description
_index.tsx / Home / index route
about.tsx /about Static route
blog.$slug.tsx /blog/:slug Dynamic segment with $ prefix
blog._index.tsx /blog Index of a nested route layout
($lang).tsx / or /:lang Optional segment with ()
$.tsx /* Splat / catch-all route
_auth.login.tsx /login Pathless layout (prefix with _)
Pathless Layouts

Prefixing a route segment with _ (e.g., _auth.login.tsx) creates a pathless layout route. It wraps child routes in a shared layout without adding a URL segment. I use it for grouping authenticated routes or marketing pages under a shared header/footer.

root.tsx All routes share this layout _index.tsx URL: / blog.tsx (layout) Shared blog chrome blog._index.tsx URL: /blog blog.$slug.tsx URL: /blog/:slug about.tsx URL: /about Remix Nested Routing Structure Each file is a route; dots in filenames encode nesting

Remix route hierarchy: dots in flat filenames encode nested layouts

Loading Data with Loaders

A loader runs on the server before the route component renders. It can query a database, call an external API, or read anything else async. Each route in the matched layout stack has its own loader, and Remix runs them in parallel, so you don't get the fetch-then-fetch-again waterfall of a client-rendered React app.

app/routes/blog.$slug.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";

// Runs on the server; never shipped to the browser
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
    include: { author: true, tags: true },
  });

  if (!post) {
    throw new Response("Post Not Found", { status: 404 });
  }

  return json({ post });
}

// The component receives data through useLoaderData: no useEffect, no loading state
export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Parallel Data Loading

Take a dashboard with a layout, a main content area and a sidebar widget, each with its own loader. When the URL matches all of them, Remix starts all three requests at once instead of one after another.

Parallel Loaders, No Waterfalls
// app/routes/dashboard.tsx: layout loader
export async function loader() {
  const user = await getUser();           // Runs in parallel ⚡
  return json({ user });
}

// app/routes/dashboard._index.tsx: child loader
export async function loader() {
  const stats = await getDashboardStats(); // Runs in parallel ⚡
  return json({ stats });
}

// app/routes/dashboard.sidebar.tsx: sibling loader
export async function loader() {
  const notifications = await getNotifications(); // Runs in parallel ⚡
  return json({ notifications });
}

// All three loaders fire simultaneously.
// Total wait time = slowest single loader, not sum of all.
Type-Safe Loaders

Using useLoaderData<typeof loader>() gives you TypeScript inference from server to component, so you don't maintain separate API types. Remix infers the return type of your json() call. One catch: data is serialized as JSON on the way over, so a Date from the loader arrives in the component as a string, and the inferred type reflects that.

Keep Server Code Out of the Bundle

Loaders and actions are stripped from the client build, but other code in a route file can still drag server modules in. If you see errors like process is not defined in the browser console, or your database client shows up in the client bundle, move that code into a .server.ts file (for example app/db.server.ts). Remix won't bundle .server modules for the browser.

Form Actions and Mutations

Mutations go through action functions, which are server-side handlers for form submissions. It's the old web model on purpose: an HTML form POSTs to a URL, the server handles it and redirects. You don't write a client-side fetch or invalidate a cache by hand. After an action finishes, Remix re-runs the loaders of every route on the page by default, so the UI picks up fresh data. If that's too many requests on a heavy page, export shouldRevalidate from a route to opt it out.

The simplest action handles a form POST and redirects on success:

import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/node";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  const body  = formData.get("body")  as string;

  await db.post.create({ data: { title, body } });

  return redirect("/blog");
}

export default function NewPost() {
  return (
    <Form method="post">
      <input name="title" placeholder="Post title" required />
      <textarea name="body" placeholder="Content" rows={8} />
      <button type="submit">Publish</button>
    </Form>
  );
}

Return errors from the action and display them in the component:

import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email    = formData.get("email") as string;
  const password = formData.get("password") as string;

  const errors: Record<string, string> = {};

  if (!email.includes("@")) errors.email = "Invalid email address";
  if (password.length < 8) errors.password = "Min 8 characters";

  if (Object.keys(errors).length) {
    return json({ errors }, { status: 400 });
  }

  await createUser({ email, password });
  return redirect("/dashboard");
}

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

  return (
    <Form method="post">
      <input name="email" type="email" />
      {actionData?.errors?.email && (
        <p className="error">{actionData.errors.email}</p>
      )}
      <input name="password" type="password" />
      {actionData?.errors?.password && (
        <p className="error">{actionData.errors.password}</p>
      )}
      <button type="submit">Register</button>
    </Form>
  );
}

useFetcher lets you submit to any route without navigating. It works well for inline edits, likes, or background saves:

import { useFetcher } from "@remix-run/react";

function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
  const fetcher = useFetcher();

  // Optimistically reflect the pending state
  const isLiking = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action="/api/like">
      <input type="hidden" name="postId" value={postId} />
      <input type="hidden" name="action" value={liked ? "unlike" : "like"} />
      <button type="submit" disabled={isLiking}>
        {liked ? "❤️ Liked" : "🤍 Like"}
      </button>
    </fetcher.Form>
  );
}

Use useNavigation and useFetcher state to build optimistic updates:

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

function SubmitButton() {
  const navigation = useNavigation();

  const isSubmitting = navigation.state === "submitting";
  const isLoading    = navigation.state === "loading";

  return (
    <button
      type="submit"
      disabled={isSubmitting || isLoading}
    >
      {isSubmitting ? "Saving..." : isLoading ? "Redirecting..." : "Save"}
    </button>
  );
}

// For optimistic list updates using fetcher.formData
function TodoItem({ todo }: { todo: Todo }) {
  const fetcher = useFetcher();

  // Read the intent from the pending form submission
  const isDeleting =
    fetcher.state === "submitting" &&
    fetcher.formData?.get("intent") === "delete";

  return (
    <li style={{ opacity: isDeleting ? 0.4 : 1 }}>
      {todo.title}
      <fetcher.Form method="post">
        <input type="hidden" name="intent" value="delete" />
        <input type="hidden" name="id" value={todo.id} />
        <button type="submit">Delete</button>
      </fetcher.Form>
    </li>
  );
}

Nested Routes and Layouts

Nested routes are where Remix pays off for bigger UIs. When a URL matches multiple route files (e.g., /dashboard/settings matches both dashboard.tsx and dashboard.settings.tsx), Remix renders them in a nested layout tree. Each layout renders an <Outlet /> where its child route appears.

This matters most when the user moves between child routes. Remix only re-fetches and re-renders the parts of the layout that actually changed. If the user goes from /dashboard/settings to /dashboard/profile, the outer dashboard.tsx layout stays mounted and its loader doesn't run again. Only the inner content swaps out.

app/routes/dashboard.tsx (Parent Layout)
import { Outlet, NavLink } from "@remix-run/react";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader() {
  const user = await getCurrentUser();
  return json({ user });
}

export default function DashboardLayout() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div className="dashboard">
      <aside className="sidebar">
        <p>Welcome, {user.name}</p>
        <nav>
          <NavLink to="/dashboard"
            className={({ isActive }) => isActive ? "active" : ""}>
            Overview
          </NavLink>
          <NavLink to="/dashboard/settings">Settings</NavLink>
          <NavLink to="/dashboard/billing">Billing</NavLink>
        </nav>
      </aside>
      <main>
        <Outlet />  {/* Child route renders here */}
      </main>
    </div>
  );
}
app/routes/dashboard.settings.tsx (Child Route)
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await getCurrentUser(request);
  return json({ user });
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const name  = formData.get("name")  as string;
  const email = formData.get("email") as string;

  await updateUser({ name, email });
  return redirect("/dashboard/settings?saved=true");
}

export default function DashboardSettings() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <section>
      <h2>Account Settings</h2>
      <Form method="post">
        <label>
          Name
          <input name="name" defaultValue={user.name} />
        </label>
        <label>
          Email
          <input name="email" type="email" defaultValue={user.email} />
        </label>
        <button type="submit">Save Changes</button>
      </Form>
    </section>
  );
}

Error Handling in Remix

Error UI in Remix lives next to the route that can fail. By exporting an ErrorBoundary component from a route file, Remix will render it in place of that route when either the loader throws, the action throws, or the component throws. The rest of the app, parent layouts included, keeps rendering normally.

app/routes/blog.$slug.tsx (Error Boundary)
import { useRouteError, isRouteErrorResponse, Link } from "@remix-run/react";

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({ where: { slug: params.slug } });

  if (!post) {
    // Throwing a Response is the idiomatic Remix way to trigger a 404
    throw new Response("Post not found", { status: 404 });
  }

  return json({ post });
}

// This ErrorBoundary replaces ONLY the blog.$slug route segment
// The nav, sidebar, and footer layouts remain intact
export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <div className="error-page">
        <h1>{error.status} - {error.statusText}</h1>
        <p>{error.data}</p>
        <Link to="/blog">← Back to Blog</Link>
      </div>
    );
  }

  // Unexpected runtime error
  const message = error instanceof Error ? error.message : "Unknown error";
  return (
    <div className="error-page">
      <h1>Something went wrong</h1>
      <p>{message}</p>
    </div>
  );
}
Re-thrown Errors Bubble Up

If a route doesn't export an ErrorBoundary, errors bubble up to the nearest ancestor that does. Always export an ErrorBoundary from root.tsx as a global catch-all to prevent blank white screens in production.

Exporting Meta from Routes

Each route can export a meta function to define dynamic <title> and <meta> tags. Remix merges these across the route hierarchy, so you get per-page SEO tags without a third-party head library:

Dynamic Meta Tags
import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data?.post) {
    return [{ title: "Post Not Found | My Blog" }];
  }
  return [
    { title: `${data.post.title} | My Blog` },
    { name: "description", content: data.post.excerpt },
    { property: "og:title", content: data.post.title },
    { property: "og:image", content: data.post.coverImage },
  ];
};

Remix vs Next.js

Both are solid full-stack React frameworks. They just make different trade-offs:

Feature Remix Next.js
Data Fetching Loaders (server-only, parallel) Server Components + fetch, RSC cache
Mutations Actions via HTML forms Server Actions (React 19+)
Routing Flat file naming with dots Nested folder structure (app/ dir)
Rendering SSR by default; no SSG SSR, SSG, ISR, RSC all available
Error Handling Per-route ErrorBoundary exports error.tsx colocated files
Deployment Any runtime (Node, Deno, CF Workers, Lambda) Vercel-optimized; others work but less polished
Progressive Enhancement First-class (forms work without JS) Possible but not the default pattern
Static Sites Limited (needs server) Strong SSG and ISR support
Bundle Size Smaller client bundle (less JS sent) RSC reduces JS; can still grow large
Learning Curve Moderate (new mental model) Gentle (large community, many tutorials)

When Should You Choose Remix?

Remix fits best in these cases:

  • Form-heavy, interactive apps: CRUD apps, dashboards, admin panels and SaaS products map cleanly onto loaders and actions.
  • Edge or non-Vercel deployments: Cloudflare Workers, Deno Deploy and AWS Lambda all have adapters, and none of them is a second-class target.
  • Progressive enhancement by default: Remix forms still submit if JavaScript fails to load, which helps on flaky mobile connections.
  • Less client state for server data: you usually don't need Redux, React Query or Zustand to cache server data. Loaders and revalidation cover most of it.

Stick with Next.js if you need static site generation (blogs, marketing sites), or if you need the largest possible ecosystem of examples and community support.

When I'd Use Remix (and When I Wouldn't)

I'd reach for Remix when most of the app is "read some data, show a form, save it, show the result." Admin panels, internal tools, account settings and checkout flows all fit that shape, and the loader/action model removes a lot of the fetch and cache code you'd otherwise write. It's also a good pick if you have to run on Cloudflare Workers or Lambda rather than Vercel.

I wouldn't use it for a mostly static site like a blog or marketing pages, where Next.js static generation or Astro gives you prebuilt HTML on a CDN with no server to run. I'd also think twice for apps where most of the state lives on the client (a canvas editor, a game, a heavy offline-first tool), because loaders and actions don't help much there. And check the version you're starting on: Remix v2's features have since moved into React Router v7, so a new project today will probably start as a React Router app, and older tutorials (including the @remix-run/* imports above) won't match the docs one to one.

A few things that bite people in production:

Worth Looking at Next

  • Remix Stacks: Official templates (Blues, Indie, Grunge) that come with a database, auth and deployment already set up
  • Prisma + Remix: The most common ORM pairing for Remix, with typed queries you call straight from loaders and actions
  • Tailwind CSS: Works well with Remix; configure it in remix.config.js with the PostCSS preset
  • Defer: Use Remix's defer() utility to stream slow data in later while fast data renders right away
  • Prefetching: Add prefetch="intent" to <Link> components to preload route data when the user hovers, so navigation feels instant
  • Resource Routes: Export a loader without a default component to build JSON endpoints, webhooks, or file download routes
Remix React Full-Stack JavaScript SSR Web Standards Loaders
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.