web-dev6 min read

Remix Tutorial: Learn Web Fundamentals from Scratch (2026)

Remix Tutorial: Learn Web Fundamentals from Scratch (2026)

Published:  |  Category: Web Dev  |  Reading time: ~15 min
Remix Tutorial: Learn Web Fundamentals from Scratch (2026)

Remix is a full-stack web framework built on web standards — the Web Fetch API, Request/Response, and FormData. Created by the React Router team (Michael Jackson and Ryan Florence), Remix embraces the web platform rather than abstracting it. It treats the browser as a thin renderer that delegates most work to the server.

Remix’s architecture centers on nested routes that load data in parallel, form-based mutations that work without JavaScript, and error boundaries that isolate failures. Acquired by Shopify in 2022, Remix powers e-commerce and content sites that need fast navigation and resilient data handling.

Nested Routes and Outlets

Remix uses file-based routing where each file in app/routes exports a component, loader, and action. Nested routes map to nested layouts automatically. The component renders child routes, enabling layouts that share UI and data boundaries.

Route modules export LoaderFunctionArgs and ActionFunctionArgs. The route tree determines which loaders run in parallel. When navigating between sibling routes, only the changing outlet re-renders — parent data is cached.

app/routes/
  _index.tsx              # /
  _layout.tsx             # Root layout
  _layout.blog.tsx        # Blog layout wrapper
  blog._index.tsx          # /blog
  blog.$slug.tsx           # /blog/hello-world
  blog.$slug.edit.tsx      # /blog/hello-world/edit
  dashboard._index.tsx     # /dashboard
  dashboard.projects.tsx   # /dashboard/projects
  dashboard.projects.$id.tsx  # /dashboard/projects/42

// app/routes/_layout.tsx
import { Outlet } from '@remix-run/react';

export default function Layout() {
  return (
    
); } // app/routes/_layout.blog.tsx export default function BlogLayout() { return (
); }

Loaders: Server-Side Data Fetching

Loaders run on the server, fetching data before the page renders. Each route can export a loader function that receives the request, params, context, and URL. Loaders return data via json() helper. Remix automatically serializes the response and makes it available to the component via useLoaderData().

Loaders can access cookies, sessions, and database connections. Multiple loaders for nested routes run in parallel. Remix caches loader data and revalidates after mutations. The shouldRevalidate function controls revalidation logic.

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

export async function loader({ params, request, context }) {
  const post = await db.posts.findUnique({
    where: { slug: params.slug },
    include: { author: true, tags: true }
  });

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

  const headers = new Headers();
  headers.set('Cache-Control', 'public, max-age=3600');

  return json({ post }, { headers });
}

export default function BlogPost() {
  const { post } = useLoaderData();

  return (
    

{post.title}

By {post.author.name} | Tags: {post.tags.map(t => t.name).join(', ')}

); }

Actions: Form Mutations

Actions handle form submissions on the server. Export an action function that receives the form data, validates it, performs mutations, and returns the result. Components in Remix work without JavaScript — the browser submits natively. useActionData() accesses validation errors returned from the action.

After an action runs, Remix revalidates all loader data on the current page, ensuring the UI reflects the new server state. useNavigation tracks form submission state. Remix supports multiple forms per page with independent loading states.

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

export async function action({ request }) {
  const formData = await request.formData();
  const title = String(formData.get('title') || '');
  const body = String(formData.get('body') || '');

  const errors = {};
  if (title.length < 3) errors.title = 'Title must be at least 3 characters';
  if (body.length < 10) errors.body = 'Body must be at least 10 characters';

  if (Object.keys(errors).length > 0) {
    return json({ errors, values: { title, body } }, { status: 400 });
  }

  const post = await db.posts.create({ data: { title, body, slug: title.toLowerCase().replace(/\s+/g, '-') } });
  return redirect(`/blog/${post.slug}`);
}

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

  return (
    
{actionData?.errors?.title &&

{actionData.errors.title}

}