Back to list
tejovanthn

remix-patterns

by tejovanthn

0🍴 0📅 Jan 23, 2026

SKILL.md


name: remix-patterns description: Best practices, patterns, and conventions for working with Remix - the web framework that embraces web fundamentals and progressive enhancement

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>
  );
}

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

Score

Total Score

40/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon