Frontend

Remix: Building Full-Stack React Apps

Mayur Dabhi
Mayur Dabhi
June 8, 2026
14 min read

Remix is a full-stack web framework built on top of React that takes a fundamentally different approach to building modern web applications. Created by Ryan Florence and Michael Jackson—the same team behind React Router—Remix embraces web standards rather than abstracting over them. The result is a framework that delivers exceptional performance through parallel data loading, resilient user experiences that work even without JavaScript, and a mental model so simple it makes other frameworks feel overcomplicated.

While frameworks like Next.js have dominated the React full-stack space, Remix challenges that status quo with a philosophy-first approach: lean on the platform, use HTTP properly, and keep the server and client in sync without magic. If you've ever been frustrated by client-side waterfalls, complex state synchronization, or bloated JavaScript bundles, Remix was designed specifically to address those pain points.

Why Remix?

Remix was acquired by Shopify in 2022 and powers the Shopify Hydrogen storefront framework. It runs on any JavaScript runtime—Node.js, Deno, Cloudflare Workers, AWS Lambda—making it one of the most deployment-flexible React frameworks available today.

What is Remix?

At its core, Remix is a compiler and server-side runtime that wraps React Router. Every Remix app is essentially a deeply integrated React Router app with a server layer built in. This is not coincidental—the same mental model you use for client-side routing maps directly to how Remix handles server requests.

Remix introduces three core primitives that form the foundation of every application:

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 — no client-side fetching required

Installation and Setup

Getting a Remix project up and running takes under two minutes. Remix provides an official create-remix CLI that scaffolds a complete starter project with your chosen server adapter and styling approach.

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—giving you full control over the 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

Remix uses a flat file naming convention to define routes. Unlike Next.js, which uses nested directories to create nested routes, Remix encodes the nesting directly in the filename using dots as separators. This approach makes the entire routing structure visible at a glance without navigating folder trees.

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. Perfect 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 routes hierarchy — flat files with dots encode nested layouts

Loading Data with Loaders

The loader function is one of Remix's most powerful features. It runs on the server before the route component renders and can fetch from databases, external APIs, or any async data source. Because each route in a layout stack exports its own loader, all data is fetched in parallel—eliminating the cascading waterfall that plagues client-rendered React apps.

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

Because every route in the layout tree exports its own loader, Remix fetches all of them simultaneously. Consider a dashboard with a sidebar, main content area, and a footer widget—all three load their data at the same time, not 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 full TypeScript type inference from server to component—no need for separate API type definitions. Remix infers the return type of your json() call automatically.

Form Actions and Mutations

Remix handles mutations through action functions—server-side handlers for form submissions. This is a deliberate return to how the web originally worked: HTML forms POST data to a URL, the server processes it and redirects. No client-side fetch, no optimistic update state machines, no cache invalidation headaches.

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 — perfect for inline edits, likes, or background syncs:

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 Remix's secret weapon for building complex UIs without complexity. 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—like Russian nesting dolls, but for URLs.

The real power shows when the user navigates 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 data is never refetched—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

Remix provides a first-class error boundary system that co-locates error UI with 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—including parent layouts—continues to render 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, giving you full SEO control without a third-party head management 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 Remix and Next.js are excellent full-stack React frameworks, but they make different trade-offs. Understanding the differences helps you pick the right tool for your project.

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) Excellent 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 shines in these scenarios:

  • Apps with heavy user interaction and form-driven workflows — CRUD apps, dashboards, admin panels, SaaS products benefit enormously from actions and loaders.
  • When deploying to edge runtimes — Cloudflare Workers, Deno Deploy, and AWS Lambda are first-class citizens. Remix's server agnosticism is unmatched.
  • When you want progressive enhancement by default — Remix forms work even if JavaScript fails to load, giving you resilient experiences in unreliable network conditions.
  • When you're tired of client-side state management for server data — No Redux, no React Query, no Zustand for server state. Loaders and revalidation handle it all.

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.

Conclusion and Next Steps

Remix represents a genuine philosophical shift in how full-stack React apps are built. By leaning on web standards—HTTP, HTML forms, the browser's native fetch and navigation APIs—it achieves better performance, simpler code, and more resilient user experiences than most client-heavy frameworks. The loader/action/component triad is the entire data layer of a Remix app; once that clicks, everything else follows naturally.

Keep Learning

  • Remix Stacks: Official templates (Blues, Indie, Grunge) for fast project bootstrapping with database, auth, and deployment pre-configured
  • Prisma + Remix: The most common ORM pairing for Remix — type-safe database queries that plug directly into loaders and actions
  • Tailwind CSS: Pairs beautifully with Remix's component model — configure in remix.config.js with the PostCSS preset
  • Defer: Use Remix's defer() utility to stream slow data progressively while fast data renders immediately
  • Prefetching: Add prefetch="intent" to <Link> components to preload route data when the user hovers — instant navigation feel
  • Resource Routes: Export a loader without a default component to build JSON endpoints, webhooks, or file download routes
"Remix is not a compromise between server and client. It's a synthesis — embrace both, fight neither."
— Ryan Florence, Co-creator of Remix

Whether you're building a SaaS dashboard, an e-commerce storefront, or a content platform, Remix gives you the tools to build fast, resilient, and maintainable full-stack applications. Its commitment to web standards means the skills you develop are transferable—you're not learning an abstraction, you're getting closer to the platform itself.

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.