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.
Builds full-stack React apps with Remix's nested routing and server-side data loading. Use when creating new Remix projects, implementing loaders/actions for forms, setting up error boundaries, or deploying to platforms like Vercel/Netlify.
/plugin marketplace add mgd34msu/goodvibes-plugin/plugin install goodvibes@goodvibes-marketThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/data-flow.mdreferences/deployment.mdreferences/routing.mdtemplates/resource-route.tstemplates/route.tsxFull-stack React framework with nested routing, server-side data loading, progressive enhancement, and bring-your-own-server flexibility.
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 | 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) |
| 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 |
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>
);
}
// 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 />;
}
// 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>
);
}
// 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 });
};
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 });
};
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" });
};
// 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>
);
}
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>
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>
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>
);
}
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>
);
}
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>
);
}
// 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>
);
}
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 });
};
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 },
];
};
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" },
];
// 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 });
};
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>
);
}
// 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) });
};
// 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 });
};
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),
},
}
);
};
| 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 |
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.