Claude Code Plugins

Community-maintained marketplace

Feedback

Builds full-stack React applications with Remix using loaders, actions, nested routes, and progressive enhancement. Use when creating Remix projects, implementing data loading, form handling, error boundaries, or deploying to various platforms.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name remix
description Builds full-stack React applications with Remix using loaders, actions, nested routes, and progressive enhancement. Use when creating Remix projects, implementing data loading, form handling, error boundaries, or deploying to various platforms.

Remix

Full-stack React framework with nested routing, server-side data loading, progressive enhancement, and bring-your-own-server flexibility.

Quick Start

Create new project:

npx create-remix@latest my-app
cd my-app
npm run dev

Manual setup:

mkdir my-remix-app && cd my-remix-app
npm init -y
npm i @remix-run/node @remix-run/react @remix-run/serve isbot react react-dom
npm i -D @remix-run/dev vite typescript @types/react @types/react-dom

Essential file structure:

app/
  root.tsx            # Root layout (required)
  routes/
    _index.tsx        # Home page (/)
    about.tsx         # /about
    posts.$slug.tsx   # /posts/:slug
  entry.client.tsx    # Client entry
  entry.server.tsx    # Server entry
vite.config.ts

File-Based Routing

Route Conventions

File Route
_index.tsx / (index route)
about.tsx /about
posts._index.tsx /posts
posts.$slug.tsx /posts/:slug
posts.$slug_.edit.tsx /posts/:slug/edit (escaping nesting)
$.tsx Splat (catch-all)

Special Characters

Character Purpose Example
. Nested URL segment posts.new.tsx -> /posts/new
$ Dynamic segment posts.$id.tsx -> /posts/:id
_ Layout nesting escape posts_.$id.tsx -> no layout
() Optional segment ($lang).about.tsx

Nested Routes

app/routes/
  dashboard.tsx           # Layout for /dashboard/*
  dashboard._index.tsx    # /dashboard
  dashboard.settings.tsx  # /dashboard/settings
  dashboard.users.tsx     # /dashboard/users
// app/routes/dashboard.tsx (Layout)
import { Outlet } from "@remix-run/react";

export default function DashboardLayout() {
  return (
    <div className="flex">
      <Sidebar />
      <main className="flex-1">
        <Outlet /> {/* Child routes render here */}
      </main>
    </div>
  );
}

Root Route

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

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Loaders (Data Fetching)

Basic Loader

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

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const posts = await db.posts.findMany();
  return json({ posts });
};

export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();

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

With Parameters

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

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

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

  return json({ post });
};

With Search Params

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const query = url.searchParams.get("q") ?? "";
  const page = Number(url.searchParams.get("page")) || 1;

  const posts = await db.posts.findMany({
    where: { title: { contains: query } },
    skip: (page - 1) * 10,
    take: 10,
  });

  return json({ posts, query, page });
};

With Headers

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const authHeader = request.headers.get("Authorization");

  // Check auth
  if (!authHeader) {
    throw new Response("Unauthorized", { status: 401 });
  }

  return json({ data: "secret" });
};

Actions (Mutations)

Basic Form Action

// app/routes/posts.new.tsx
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/node";

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

  // Validate
  const errors: Record<string, string> = {};
  if (!title) errors.title = "Title is required";
  if (!content) errors.content = "Content is required";

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

  // Create post
  await db.posts.create({ data: { title, content } });

  return redirect("/posts");
};

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

  return (
    <Form method="post">
      <div>
        <label>
          Title
          <input type="text" name="title" />
        </label>
        {actionData?.errors?.title && (
          <p className="error">{actionData.errors.title}</p>
        )}
      </div>

      <div>
        <label>
          Content
          <textarea name="content" />
        </label>
        {actionData?.errors?.content && (
          <p className="error">{actionData.errors.content}</p>
        )}
      </div>

      <button type="submit">Create Post</button>
    </Form>
  );
}

Multiple Actions (Intent Pattern)

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const intent = formData.get("intent");

  switch (intent) {
    case "create":
      return createItem(formData);
    case "update":
      return updateItem(formData);
    case "delete":
      return deleteItem(formData);
    default:
      throw new Response("Invalid intent", { status: 400 });
  }
};

// In component
<Form method="post">
  <input type="hidden" name="id" value={item.id} />
  <button type="submit" name="intent" value="update">Update</button>
  <button type="submit" name="intent" value="delete">Delete</button>
</Form>

Forms

Form Component

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

// POST to current route
<Form method="post">
  <input name="email" type="email" />
  <button type="submit">Submit</button>
</Form>

// POST to different route
<Form method="post" action="/subscribe">
  <input name="email" type="email" />
  <button type="submit">Subscribe</button>
</Form>

// GET (search)
<Form method="get" action="/search">
  <input name="q" type="search" />
  <button type="submit">Search</button>
</Form>

Pending States

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

export default function CreatePost() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <input name="title" disabled={isSubmitting} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating..." : "Create"}
      </button>
    </Form>
  );
}

useFetcher (Non-Navigation Mutations)

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

export function LikeButton({ postId }: { postId: string }) {
  const fetcher = useFetcher();
  const isLiking = fetcher.state === "submitting";

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

Optimistic UI

export function TodoItem({ todo }) {
  const fetcher = useFetcher();

  // Optimistic value
  const isCompleted = fetcher.formData
    ? fetcher.formData.get("completed") === "true"
    : todo.completed;

  return (
    <fetcher.Form method="post">
      <input type="hidden" name="id" value={todo.id} />
      <input
        type="checkbox"
        name="completed"
        value="true"
        checked={isCompleted}
        onChange={(e) => fetcher.submit(e.target.form)}
      />
      <span className={isCompleted ? "line-through" : ""}>
        {todo.title}
      </span>
    </fetcher.Form>
  );
}

Error Handling

Error Boundary

// app/routes/posts.$slug.tsx
import { useRouteError, isRouteErrorResponse } 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>Error</h1>
      <p>{error instanceof Error ? error.message : "Unknown error"}</p>
    </div>
  );
}

Throwing Responses

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

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

  return json({ post });
};

Meta & Links

Meta Tags

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

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

// Dynamic meta from loader
export const meta: MetaFunction<typeof loader> = ({ data }) => {
  return [
    { title: data?.post.title ?? "Post" },
    { name: "description", content: data?.post.excerpt },
  ];
};

Links (CSS, Fonts)

import type { LinksFunction } from "@remix-run/node";
import styles from "~/styles/app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: styles },
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
];

Resource Routes (API)

// app/routes/api.posts.tsx
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";

// GET /api/posts
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const posts = await db.posts.findMany();
  return json(posts);
};

// POST /api/posts
export const action = async ({ request }: ActionFunctionArgs) => {
  const body = await request.json();
  const post = await db.posts.create({ data: body });
  return json(post, { status: 201 });
};

Streaming (Deferred Data)

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

export const loader = async () => {
  // Fast data - awaited
  const criticalData = await getCriticalData();

  // Slow data - deferred
  const slowDataPromise = getSlowData();

  return defer({
    criticalData,
    slowData: slowDataPromise,
  });
};

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

  return (
    <div>
      <h1>{criticalData.title}</h1>

      <Suspense fallback={<p>Loading...</p>}>
        <Await resolve={slowData}>
          {(data) => <SlowComponent data={data} />}
        </Await>
      </Suspense>
    </div>
  );
}

Sessions & Cookies

// app/sessions.server.ts
import { createCookieSessionStorage } from "@remix-run/node";

export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
  },
});

export const { getSession, commitSession, destroySession } = sessionStorage;
// Usage in loader/action
import { getSession, commitSession } from "~/sessions.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", user.id);

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
};

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");

  if (!userId) {
    throw redirect("/login");
  }

  return json({ user: await getUser(userId) });
};

Common Patterns

Protected Routes

// app/utils/auth.server.ts
export async function requireUser(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");

  if (!userId) {
    throw redirect("/login");
  }

  const user = await db.users.findUnique({ where: { id: userId } });

  if (!user) {
    throw redirect("/login");
  }

  return user;
}

// In route
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const user = await requireUser(request);
  return json({ user });
};

Flash Messages

export const action = async ({ request }: ActionFunctionArgs) => {
  const session = await getSession(request.headers.get("Cookie"));
  session.flash("success", "Post created successfully!");

  return redirect("/posts", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
};

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const session = await getSession(request.headers.get("Cookie"));
  const message = session.get("success");

  return json(
    { message },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
};

Best Practices

  1. Colocate data with UI - Keep loaders/actions in the same file as components
  2. Use Form over fetch - Progressive enhancement by default
  3. Throw responses for errors - Triggers error boundaries
  4. Use resource routes for APIs - Not everything needs UI
  5. Defer slow data - Stream non-critical content
  6. Validate on server - Never trust client data

Common Mistakes

Mistake Fix
Fetching in useEffect Use loaders instead
Using fetch for mutations Use Form/useFetcher
Not handling loading states Check navigation.state
Forgetting error boundaries Add ErrorBoundary export
Client-side state for server data Use loader + revalidation

Reference Files

Templates