remix-patterns
SKILL.md
Remix Development Patterns
This skill provides comprehensive guidance for building Remix applications following best practices and framework conventions.
Core Philosophy
Remix embraces:
- Web Fundamentals: Work with HTTP, not against it
- Progressive Enhancement: Apps should work without JavaScript
- Server-First: Do the work on the server, send HTML
- Nested Routes: Compose UIs with route hierarchy
- No Client State: Use the URL and server as the source of truth
Route Structure
Basic Route Module
Every route module can export these functions:
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
// Loader: Fetch data on GET requests
export async function loader({ params, request }: LoaderFunctionArgs) {
const post = await getPost(params.id);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return json({ post });
}
// Action: Handle mutations (POST, PUT, DELETE)
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
await updatePost(params.id, { title });
return json({ success: true });
}
// Component: Render the UI
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<div>
<h1>{post.title}</h1>
<Form method="post">
<input name="title" defaultValue={post.title} />
<button type="submit">Update</button>
</Form>
</div>
);
}
Data Loading Patterns
Pattern 1: Simple Data Fetching
export async function loader({ params }: LoaderFunctionArgs) {
const [user, posts] = await Promise.all([
db.user.findUnique({ where: { id: params.userId } }),
db.post.findMany({ where: { userId: params.userId } })
]);
return json({ user, posts });
}
Pattern 2: Authentication Checks
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireAuth(request);
const user = await db.user.findUnique({ where: { id: userId } });
return json({ user });
}
// Helper function
async function requireAuth(request: Request) {
const userId = await getUserId(request);
if (!userId) {
throw redirect("/login");
}
return userId;
}
Pattern 3: Search with URL Params
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q") || "";
const page = Number(url.searchParams.get("page")) || 1;
const results = await searchPosts({ query, page });
return json({ results, query, page });
}
export default function Search() {
const { results, query } = useLoaderData<typeof loader>();
return (
<div>
<Form method="get">
<input name="q" defaultValue={query} />
<button type="submit">Search</button>
</Form>
{/* Results */}
</div>
);
}
Pattern 4: Dependent Loaders (Parent/Child)
// app/routes/users.$userId.tsx (parent)
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.user.findUnique({ where: { id: params.userId } });
return json({ user });
}
// app/routes/users.$userId.posts.tsx (child)
export async function loader({ params }: LoaderFunctionArgs) {
// Can access parent data via useRouteLoaderData
const posts = await db.post.findMany({ where: { userId: params.userId } });
return json({ posts });
}
export default function UserPosts() {
const { posts } = useLoaderData<typeof loader>();
const { user } = useRouteLoaderData<typeof parentLoader>("routes/users.$userId");
return <div>{/* ... */}</div>;
}
Form Handling Patterns
Pattern 1: Basic Form Submission
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
const content = formData.get("content");
const post = await db.post.create({
data: { title, content }
});
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
return (
<Form method="post">
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</Form>
);
}
Pattern 2: Form with Validation
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
if (!result.success) {
return json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
await createUser(result.data);
return redirect("/dashboard");
}
export default function Signup() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" />
{actionData?.errors.fieldErrors.email && (
<span>{actionData.errors.fieldErrors.email}</span>
)}
<input name="password" type="password" />
{actionData?.errors.fieldErrors.password && (
<span>{actionData.errors.fieldErrors.password}</span>
)}
<button type="submit">Sign Up</button>
</Form>
);
}
Pattern 3: Optimistic UI
import { useFetcher } from "@remix-run/react";
export default function Todo({ todo }) {
const fetcher = useFetcher();
// Optimistic state
const isComplete = fetcher.formData
? fetcher.formData.get("complete") === "true"
: todo.complete;
return (
<fetcher.Form method="post" action={`/todos/${todo.id}`}>
<input type="hidden" name="complete" value={String(!isComplete)} />
<button type="submit">
{isComplete ? "✓" : "○"} {todo.title}
</button>
</fetcher.Form>
);
}
Pattern 4: Multiple Actions per Route
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "delete":
await deletePost(formData.get("id"));
return json({ success: true });
case "update":
await updatePost(formData.get("id"), {
title: formData.get("title")
});
return json({ success: true });
default:
throw new Response("Invalid intent", { status: 400 });
}
}
export default function Post() {
return (
<>
<Form method="post">
<input type="hidden" name="intent" value="update" />
<input name="title" />
<button type="submit">Update</button>
</Form>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<button type="submit">Delete</button>
</Form>
</>
);
}
Error Handling
Pattern 1: Error Boundaries
// app/routes/posts.$id.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({ where: { id: params.id } });
if (!post) {
throw new Response("Post not found", { status: 404 });
}
return json({ post });
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return <div>Something went wrong!</div>;
}
Pattern 2: Nested Error Boundaries
// Root error boundary (app/root.tsx)
export function ErrorBoundary() {
return (
<html>
<body>
<h1>Application Error</h1>
<p>Sorry, something went wrong.</p>
</body>
</html>
);
}
// Route-specific error boundary
export function ErrorBoundary() {
return (
<div>
<h1>Post Error</h1>
<p>Couldn't load this post.</p>
</div>
);
}
Nested Routes
Pattern 1: Layout Routes
// app/routes/dashboard.tsx (layout)
import { Outlet } from "@remix-run/react";
export default function Dashboard() {
return (
<div>
<nav>{/* Dashboard navigation */}</nav>
<main>
<Outlet /> {/* Child routes render here */}
</main>
</div>
);
}
// app/routes/dashboard.settings.tsx (child)
export default function Settings() {
return <div>Settings content</div>;
}
// app/routes/dashboard.profile.tsx (child)
export default function Profile() {
return <div>Profile content</div>;
}
Pattern 2: Pathless Layouts
// app/routes/_auth.tsx (pathless layout - note the underscore)
export default function AuthLayout() {
return (
<div className="auth-container">
<Outlet />
</div>
);
}
// app/routes/_auth.login.tsx
// URL: /login (not /_auth/login)
export default function Login() {
return <Form>{/* login form */}</Form>;
}
// app/routes/_auth.signup.tsx
// URL: /signup
export default function Signup() {
return <Form>{/* signup form */}</Form>;
}
Resource Routes
Pattern 1: Image Generation
// app/routes/og.$id.tsx
import { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.id);
const image = await generateOGImage(post);
return new Response(image, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable"
}
});
}
Pattern 2: File Downloads
// app/routes/downloads.$fileId.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const file = await getFile(params.fileId);
const stream = await getFileStream(file.path);
return new Response(stream, {
headers: {
"Content-Type": file.mimeType,
"Content-Disposition": `attachment; filename="${file.name}"`
}
});
}
Best Practices
1. Prefer Server-Side Logic
❌ Don't fetch on the client:
export default function Posts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch("/api/posts").then(r => r.json()).then(setPosts);
}, []);
}
✅ Do use loaders:
export async function loader() {
const posts = await db.post.findMany();
return json({ posts });
}
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return <div>{/* render posts */}</div>;
}
2. Use Form Component
❌ Don't use fetch for mutations:
function handleSubmit(e) {
e.preventDefault();
fetch("/api/posts", { method: "POST", body: formData });
}
✅ Do use Form:
export default function NewPost() {
return (
<Form method="post">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}
3. Colocate Related Code
✅ Keep route concerns together:
// app/routes/posts.$id.tsx
// - loader (data fetching)
// - action (mutations)
// - component (UI)
// - ErrorBoundary (error handling)
// All in one file!
4. Use Type Safety
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
// TypeScript knows about params, request, etc.
}
export default function Component() {
// Automatic type inference from loader!
const data = useLoaderData<typeof loader>();
// ^? data is typed based on loader return
}
5. Handle Loading States
import { useNavigation } from "@remix-run/react";
export default function Component() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<button disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>
</Form>
);
}
Common Gotchas
1. Loader Return Types
Always use json() or Response:
✅ return json({ data });
✅ return new Response(data);
❌ return { data }; // Won't work correctly
2. Form Data is Always Strings
const formData = await request.formData();
const age = formData.get("age"); // This is a string!
// Convert to number
const ageNumber = Number(age);
3. Redirects Stop Execution
export async function action({ request }: ActionFunctionArgs) {
if (!isValid) {
return json({ error: "Invalid" }); // Continues execution
}
throw redirect("/login"); // Stops execution immediately
}
Performance Tips
1. Parallel Data Loading
export async function loader() {
// These load in parallel!
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments()
]);
return json({ user, posts, comments });
}
2. Cache Headers
export async function loader() {
const data = await getStaticData();
return json(data, {
headers: {
"Cache-Control": "public, max-age=3600"
}
});
}
3. Defer for Streaming
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader() {
return defer({
critical: await getCriticalData(), // Wait for this
deferred: getDeferredData() // Don't wait
});
}
export default function Component() {
const { critical, deferred } = useLoaderData<typeof loader>();
return (
<div>
<div>{critical}</div>
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={deferred}>
{(data) => <div>{data}</div>}
</Await>
</Suspense>
</div>
);
}
Further Reading
- Remix Docs: https://remix.run/docs
- Remix Guide: https://remix.run/guide
- Remix Examples: https://github.com/remix-run/examples
Weekly Installs
8
Repository
tejovanthn/rasikalifeFirst Seen
10 days ago
Security Audits
Installed on
opencode8
gemini-cli8
claude-code8
github-copilot8
codex8
kimi-cli8