Claude Code로 Remix/React Router 개발하기: loader와 action 실전
Claude Code로 Remix 스타일 앱을 구현합니다. loader, action, 오류 경계, SEO, 리뷰 프롬프트를 다룹니다.
2026년에 Remix를 볼 때의 기준
2026년에 Remix 개발을 이야기할 때는 Remix v2 유지보수만 생각하면 부족합니다. Remix의 많은 프레임워크 기능이 React Router v7 Framework Mode에 들어갔고, Remix 공식 문서도 최신 기능은 React Router 문서를 보도록 안내합니다. 그래서 Claude Code에 작업을 맡길 때는 “기존 Remix v2 코드에 맞춘 수정”인지, “React Router v7 Framework Mode의 새 프로젝트”인지 먼저 정해야 합니다. 이 말을 빼면 Claude Code가 오래된@remix-run/* import, 새로운react-router import, 클라이언트fetch 코드를 섞을 수 있습니다.
초보자에게는 이렇게 설명하는 편이 가장 쉽습니다. Remix/React Router 방식은 route 단위로loader와action을 두고, 서버 처리와 UI를 가까이 붙이는 설계입니다. loader는 화면을 그리기 전에 데이터를 읽는 곳이고, action은 폼 제출과 데이터 변경을 처리하는 곳입니다. UI, 에러 경계, SEO도 같은 route 주변에 모이므로 Claude Code에 “이 route의 읽기, 쓰기, 실패, 검색 노출을 같이 리뷰해줘”라고 요청하기 좋습니다.
이 글은 React Router v7 Framework Mode를 기준으로 작은 상품 앱을 만듭니다. 상품 목록, 상품 상세, 문의 폼만으로도loader 데이터 조회,action 폼 처리,ErrorBoundary 404 처리, SEO 메타, Claude Code 리뷰 프롬프트를 모두 확인할 수 있습니다. 업데이트 시 Remix Docs, React Router v7 release, Route Module docs, Error Boundaries docs, Form API를 확인했습니다.
flowchart LR
A["URL / route"] --> B["loader: 데이터 읽기"]
B --> C["UI component"]
C --> D["Form 제출"]
D --> E["action: 검증과 저장"]
E --> B
B --> F["ErrorBoundary"]
E --> F
모드를 먼저 결정한다
React Router v7은 Declarative, Data, Framework 방식으로 사용할 수 있습니다. Remix에 가장 가까운 경험은 Framework Mode입니다. route module, 서버 렌더링,loader, action, 타입 생성, 배포 구성이 한 프로젝트 형태로 묶입니다.
| 상황 | 선택 | Claude Code에 말할 내용 |
|---|---|---|
| 새 업무 앱 | React Router v7 Framework Mode | create-react-router와 route module 기준 |
| 기존 Remix v2 | 기존 import 유지 | 현재@remix-run/* 스타일에 맞춰 수정 |
| 기존 React SPA | Data Mode부터 도입 | 한 화면만 loader/action으로 옮기기 |
| 간단한 LP와 폼 | Framework 또는 SPA Mode | SSR과 서버 action 필요 여부를 먼저 결정 |
이 결정을 먼저 하지 않으면 후반 리뷰 비용이 커집니다. Claude Code는 빠르지만, 기준이 없으면 여러 시대의 예제를 섞습니다. 이 글의 코드는 신규 프로젝트에 가까운 React Router v7 Framework Mode를 전제로 합니다.
실행 가능한 미니 프로젝트 만들기
먼저 작은 앱을 만듭니다. 처음부터 DB, 인증, 결제까지 붙이면 어디서 문제가 생겼는지 보기가 어렵습니다.
npx create-react-router@latest rr-claude-shop
cd rr-claude-shop
npm install
npm run dev
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("products", "routes/products.tsx"),
route("products/:productId", "routes/products.$productId.tsx"),
route("contact", "routes/contact.tsx"),
] satisfies RouteConfig;
서버 전용 데이터 모듈을 하나 둡니다. 나중에 Prisma, Drizzle, Supabase, 사내 API로 바꿔도 route는 함수만 호출하면 됩니다.
// app/data/products.server.ts
export type Product = {
id: string;
name: string;
description: string;
price: number;
};
const products: Product[] = [
{
id: "starter",
name: "Claude Code Starter Kit",
description: "Small prompts and review checklists for the first team rollout.",
price: 9800,
},
{
id: "team",
name: "Team Workflow Pack",
description: "Route reviews, test prompts, and deployment checklists for teams.",
price: 29800,
},
];
const leads: Array<{ id: string; email: string; message: string }> = [];
export async function listProducts(query = "") {
const q = query.trim().toLowerCase();
if (!q) return products;
return products.filter((product) =>
`${product.name} ${product.description}`.toLowerCase().includes(q),
);
}
export async function getProduct(productId: string) {
return products.find((product) => product.id === productId) ?? null;
}
export async function saveLead(input: { email: string; message: string }) {
const lead = { id: crypto.randomUUID(), ...input };
leads.push(lead);
return lead;
}
사례1: loader로 목록 읽기
loader는 route를 렌더링하기 전에 필요한 데이터를 모으는 함수입니다. 초기 데이터를 컴포넌트 안의useEffect에서 가져오는 방식보다, 로딩과 에러와 SEO를 route 단위로 보기가 쉽습니다.
// app/routes/products.tsx
import { Form, Link, useLoaderData, useNavigation } from "react-router";
import { listProducts } from "~/data/products.server";
export async function loader({ request }: { request: Request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";
const products = await listProducts(q);
return { q, products };
}
export default function ProductsRoute() {
const { q, products } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const searching = navigation.location?.pathname === "/products";
return (
<main>
<title>Products | Claude Code Shop</title>
<meta
name="description"
content="Browse Claude Code workflow products and team enablement kits."
/>
<h1>Products</h1>
<Form method="get" role="search">
<label>
Search
<input name="q" defaultValue={q} placeholder="workflow" />
</label>
<button type="submit">{searching ? "Searching..." : "Search"}</button>
</Form>
<ul>
{products.map((product) => (
<li key={product.id}>
<Link to={`/products/${product.id}`}>{product.name}</Link>
<p>{product.description}</p>
<strong>{product.price.toLocaleString()} JPY</strong>
</li>
))}
</ul>
</main>
);
}
React Router 문서는 새 코드에서 React 19의 기본<title>과<meta>사용을 권장합니다. 기존 프로젝트가 route의meta() export를 쓰고 있다면 그 관례를 지키면 됩니다. 중요한 것은 각 route에서 검색 의도와 중복 여부를 검토하는 것입니다.
사례2: action으로 문의 폼 처리
action은 폼 제출과 데이터 변경을 받는 위치입니다. <Form>은 JavaScript가 로드되기 전에도 HTML 폼으로 동작하고, 로드 후에는 React Router가 제출 상태와 재검증을 처리합니다.
// app/routes/contact.tsx
import { Form, useActionData, useNavigation } from "react-router";
import { saveLead } from "~/data/products.server";
type ActionData =
| { ok: true; leadId: string }
| { ok: false; errors: { email?: string; message?: string } };
export async function action({ request }: { request: Request }): Promise<ActionData> {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
const errors: { email?: string; message?: string } = {};
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = "Enter a valid email address.";
}
if (message.length < 20) {
errors.message = "Tell us at least 20 characters about your situation.";
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
const lead = await saveLead({ email, message });
return { ok: true, leadId: lead.id };
}
실제 업무에서는 CSRF, rate limit, 스팸 필터, 메일 발송, CRM 연동이 추가됩니다. Claude Code에는 “폼 만들어줘”가 아니라 “action에서 서버 검증, 필드 에러, 제출 중 비활성화, 성공 메시지까지 구현”처럼 조건을 줘야 합니다.
사례3: ErrorBoundary로 장애 범위 줄이기
상품이 없을 때 전체 앱을 깨뜨릴 필요는 없습니다. 해당 route의 오류 경계만 404를 보여주면 됩니다.
// app/routes/products.$productId.tsx
import { data, isRouteErrorResponse, Link, useLoaderData } from "react-router";
import { getProduct } from "~/data/products.server";
export async function loader({ params }: { params: { productId?: string } }) {
const productId = params.productId;
if (!productId) {
throw data("Missing product id", { status: 400 });
}
const product = await getProduct(productId);
if (!product) {
throw data("Product not found", { status: 404 });
}
return { product };
}
export function ErrorBoundary({ error }: { error: unknown }) {
if (isRouteErrorResponse(error)) {
return (
<main>
<h1>{error.status === 404 ? "Product not found" : "Could not load product"}</h1>
<p>{error.data}</p>
<Link to="/products">Back to products</Link>
</main>
);
}
return <main><h1>Unexpected error</h1><p>Please try again later.</p></main>;
}
리뷰 포인트는 명확합니다. 예상 가능한 404와 400은 사용자에게 다음 행동을 보여주고, 예상하지 못한 오류는 stack, 환경 변수, DB 메시지를 노출하지 않아야 합니다.
SEO와 내부 링크
Remix 스타일 route는 SEO를 나중에 붙이는 구조가 아닙니다. route가 URL, 데이터, 사용자 행동을 이미 알고 있기 때문에 title과 description도 그곳에서 결정하기 쉽습니다. 상품 목록은 비교 가능한 항목을 설명하고, 상품 상세는 상품명을 먼저 쓰며, 문의 페이지는 어떤 상담을 받을 수 있는지 알려줍니다.
내부 링크는 체류 시간을 늘리는 데도 중요합니다. 이 글은 Claude Code React 개발, Claude Code API 개발, 에러 처리 패턴과 함께 읽히도록 설계하는 것이 좋습니다.
Claude Code 리뷰 프롬프트
React Router v7 Framework Mode route module을 리뷰해주세요.
확인할 것:
1. loader가 secret, 내부 필드, 과도한 데이터를 반환하지 않는지
2. action에 서버 검증, 필드 에러, 제출 중 상태, 성공 상태가 있는지
3. ErrorBoundary가 404/400/unknown error를 나누고 stack을 노출하지 않는지
4. Form이 JavaScript 없이도 기본 제출 가능한지
5. title/description이 중복되지 않고 검색 의도에 맞는지
6. 오래된 Remix v2 import와 React Router v7 import가 섞이지 않았는지
문제는 심각도순으로 쓰고, 최소 수정 diff와 확인 명령을 제안해주세요.
자주 생기는 실패
첫째, 오래된 import와 새 import를 섞는 일입니다. 유지보수라면 기존 방식을 따르고, 신규라면 React Router v7 문서를 따릅니다. 둘째,loader에서 너무 많은 데이터를 반환하는 일입니다. 브라우저에 필요 없는 원가, 내부 메모, 관리자 플래그는 빼야 합니다. 셋째, 브라우저 검증만 믿는 일입니다. 서버action은 모든 중요 입력을 다시 검사해야 합니다. 넷째, 오류 경계에error.stack을 그대로 보여주는 일입니다. 다섯째, SEO가 자동으로 해결된다고 생각해 중복 title과 비어 있는 description을 남기는 일입니다. 여섯째, action 뒤의 loader 재검증을 이해하지 못해 작은 폼 제출마다 무거운 조회가 반복되는 일입니다.
수익화와 실제 테스트 결과
Remix/React Router 주제는 폼, SEO, 데이터 조회, 오류 처리, 배포가 한 번에 연결되기 때문에 상담이나 템플릿 상품으로 이어지기 좋습니다. 개인 연습은 무료 치트시트에서 시작하고, 팀에서 React Router v7 도입, Remix v2 유지보수, 폼 설계, Claude Code 리뷰 운영까지 정리하려면 Claude Code 교육과 도입 상담을 확인하세요. 재사용 가능한 프롬프트와 체크리스트는 상품 목록에 모을 수 있습니다.
Masa가 작은 프로젝트에서 테스트했을 때, 상품 목록 검색, 상품 상세 404, 문의 폼 검증, 성공 메시지가 모두 route 단위로 확인되었습니다. “Remix로 폼 만들어줘”라고만 하면 오래된 import와 클라이언트fetch가 섞였지만, “Framework Mode, route module, loader/action, ErrorBoundary, title/meta”를 지정하자 diff가 작고 리뷰하기 쉬워졌습니다. 결론은 단순합니다. Claude Code에게 빠르게 쓰게 하기 전에 route의 책임을 먼저 고정하면, 나중에 고치는 양이 줄어듭니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.