Aller au contenu

Remix Cheat Sheet

Overview

Remix is a full-stack web framework built on React that embraces web standards and modern web development practices. It uses nested routing, server-side rendering, and progressive enhancement to deliver fast, resilient user experiences. Remix handles data loading and mutations through loader and action functions that run on the server, keeping sensitive logic and data out of the client bundle while providing seamless data flow between server and UI.

Originally created by Ryan Florence and Michael Jackson (creators of React Router), Remix was open-sourced in 2022 and later acquired by Shopify. The framework leverages the Web Fetch API, FormData, and HTTP caching to build applications that work even before JavaScript loads in the browser. Remix v2 merged with React Router v7, unifying routing and data patterns under a single API.

Installation

Create New Project

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

# With specific template
npx create-remix@latest --template remix-run/indie-stack my-app
npx create-remix@latest --template remix-run/blues-stack my-app

# Navigate and start
cd my-app
npm install
npm run dev

Project Templates

TemplateDescription
indie-stackSQLite, Prisma, Tailwind, auth, testing
blues-stackPostgreSQL, Prisma, Docker, CI/CD
grunge-stackAWS Lambda, DynamoDB, serverless
BasicMinimal Remix app

Project Structure

PathDescription
app/root.tsxRoot layout component
app/routes/File-based routes
app/components/Shared React components
app/models/Data models / DB queries
app/utils/Utility functions
app/entry.server.tsxServer entry point
app/entry.client.tsxClient entry point
public/Static assets
remix.config.jsRemix configuration

Routing

File-Based Routes

FileURLDescription
app/routes/_index.tsx/Home page
app/routes/about.tsx/aboutStatic page
app/routes/blog.tsx/blogBlog layout
app/routes/blog._index.tsx/blogBlog index
app/routes/blog.$slug.tsx/blog/:slugDynamic param
app/routes/blog.$.tsx/blog/*Splat/catch-all
app/routes/_auth.login.tsx/loginPathless layout
app/routes/files.$.tsx/files/*Catch-all route

Route Module

import type { LoaderFunctionArgs, ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, Form } from "@remix-run/react";

// Meta tags
export const meta: MetaFunction = () => {
  return [
    { title: "My Page" },
    { name: "description", content: "Page description" },
  ];
};

// Server-side data loading (GET requests)
export async function loader({ request, params }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const query = url.searchParams.get("q");
  const posts = await db.post.findMany({ where: { title: { contains: query } } });
  return json({ posts, query });
}

// Server-side form handling (POST/PUT/DELETE)
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get("title");
  const body = formData.get("body");

  const errors: Record<string, string> = {};
  if (!title) errors.title = "Title is required";
  if (!body) errors.body = "Body is required";

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

  const post = await db.post.create({ data: { title, body } });
  return redirect(`/blog/${post.slug}`);
}

// Component
export default function BlogPage() {
  const { posts, query } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();

  return (
    <div>
      <h1>Blog</h1>

      <Form method="post">
        <input name="title" />
        {actionData?.errors?.title && <p>{actionData.errors.title}</p>}
        <textarea name="body" />
        <button type="submit">Create Post</button>
      </Form>

      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Data Loading Patterns

Loader Patterns

// Basic loader
export async function loader() {
  const users = await getUsers();
  return json({ users });
}

// With authentication
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  if (!user) throw redirect("/login");

  const data = await getUserData(user.id);
  return json({ user, data });
}

// With headers (caching)
export async function loader() {
  const data = await fetchData();
  return json(data, {
    headers: {
      "Cache-Control": "public, max-age=300",
    },
  });
}

// Throwing responses
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await getPost(params.slug);
  if (!post) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ post });
}

Using Data in Components

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

export default function Page() {
  const data = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const fetcher = useFetcher();

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

  return (
    <div>
      {/* Fetcher for non-navigation mutations */}
      <fetcher.Form method="post" action="/api/subscribe">
        <input name="email" />
        <button disabled={fetcher.state !== "idle"}>
          {fetcher.state === "submitting" ? "Subscribing..." : "Subscribe"}
        </button>
      </fetcher.Form>
    </div>
  );
}

Forms and Actions

Form Patterns

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

export default function ContactForm() {
  const navigation = useNavigation();
  const actionData = useActionData<typeof action>();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <label>
        Name: <input name="name" required />
      </label>

      <label>
        Email: <input name="email" type="email" required />
      </label>

      {/* Intent-based actions */}
      <button type="submit" name="intent" value="save">Save Draft</button>
      <button type="submit" name="intent" value="publish">Publish</button>
    </Form>
  );
}

// Action handler
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get("intent");

  switch (intent) {
    case "save":
      await saveDraft(Object.fromEntries(formData));
      return json({ success: true });
    case "publish":
      await publish(Object.fromEntries(formData));
      return redirect("/posts");
    default:
      throw new Response("Invalid intent", { status: 400 });
  }
}

Configuration

remix.config.js

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  ignoredRouteFiles: ["**/.*"],
  serverModuleFormat: "esm",
  tailwind: true,
  postcss: true,
  future: {
    v3_fetcherPersist: true,
    v3_relativeSplatPath: true,
    v3_throwAbortReason: true,
  },
};

Environment Variables

// Only in loaders/actions (server-side)
const API_KEY = process.env.API_KEY;

// For client-side, expose via loader
export async function loader() {
  return json({
    ENV: {
      PUBLIC_API_URL: process.env.PUBLIC_API_URL,
    },
  });
}

Advanced Usage

Error Boundaries

import { isRouteErrorResponse, useRouteError } from "@remix-run/react";

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

  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  return (
    <div>
      <h1>Unexpected Error</h1>
      <p>{error instanceof Error ? error.message : "Unknown error"}</p>
    </div>
  );
}

Resource Routes (API Endpoints)

// app/routes/api.users.tsx (no default export = resource route)
export async function loader({ request }: LoaderFunctionArgs) {
  const users = await getUsers();
  return json(users);
}

export async function action({ request }: ActionFunctionArgs) {
  if (request.method === "DELETE") {
    const formData = await request.formData();
    await deleteUser(formData.get("id") as string);
    return json({ success: true });
  }
  return json({ error: "Method not allowed" }, { status: 405 });
}

Streaming with defer

import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";

export async function loader() {
  const criticalData = await getCriticalData();  // Awaited
  const slowData = getSlowData();                 // Promise (not awaited)

  return defer({
    critical: criticalData,
    slow: slowData,
  });
}

export default function Page() {
  const { critical, slow } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>{critical.title}</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <Await resolve={slow}>
          {(data) => <SlowComponent data={data} />}
        </Await>
      </Suspense>
    </div>
  );
}

Troubleshooting

ProblemSolution
Hydration mismatchEnsure server/client render same content; check useEffect for client-only code
Loader not re-runningCheck route nesting; use shouldRevalidate for control
Form not submittingEnsure method="post" on Form; check action function exists
404 on routesVerify file naming convention; check routes/ directory
Environment vars undefinedServer-only vars in loader; use window.ENV for client
Stale data after mutationRemix auto-revalidates; check if shouldRevalidate blocks it
CSS not loadingCheck links export in route; verify build pipeline
Deploy errorsCheck server adapter matches target (Node, Vercel, Cloudflare)