Remix & React Router v7 완벽 가이드 — 웹 표준·SSR·Progressive Enhancement의 이상향

Remix & React Router v7 완벽 가이드 — 웹 표준·SSR·Progressive Enhancement의 이상향

이 글의 핵심

Remix는 "웹 표준을 최대한 활용해 복잡도를 줄인다"는 철학의 React 메타 프레임워크입니다. 2021년 오픈소스화 후 Shopify 인수, 2024년 React Router v7와 통합돼 단일 프로젝트로 통합되었고, SSR·Form·Progressive Enhancement를 1급으로 지원합니다. Next.js가 Server Components로 간 길과 반대로 Remix는 웹 표준 Form·loader·action API를 극한까지 활용하는 방향으로 진화했습니다.

설치

pnpm create react-router@latest my-app
# 템플릿: basic / classic-compiler / minimal / spa / ssr
cd my-app && pnpm dev

라우팅

File-based

app/routes/
  _index.tsx                → /
  about.tsx                 → /about
  blog.$slug.tsx            → /blog/:slug
  blog._index.tsx           → /blog
  posts.tsx                 → /posts (outlet을 가진 레이아웃)
    posts.$id.tsx           → /posts/:id (posts.tsx의 Outlet에 렌더)

.tsx 파일이 route이고, 다른 컴포넌트는 components/ 디렉터리에.

Loader: 데이터 페칭

// app/routes/blog.$slug.tsx
import { LoaderFunctionArgs, useLoaderData } from "react-router"
import { json } from "react-router"

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.query.posts.findFirst({
    where: eq(posts.slug, params.slug!),
  })
  if (!post) throw new Response("Not Found", { status: 404 })
  return json({ post })
}

export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>()
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}
  • SSR에서 서버 실행, 클라이언트 네비게이션 시 fetch로 요청
  • 타입 자동 추론 (useLoaderData<typeof loader>)

Action: Mutation

// app/routes/posts.new.tsx
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router"
import { json } from "react-router"
import { z } from "zod"

const schema = z.object({
  title: z.string().min(1),
  body: z.string().min(10),
})

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().fieldErrors }, { status: 400 })
  }
  
  const [post] = await db.insert(posts).values(result.data).returning()
  return redirect(`/posts/${post.id}`)
}

export default function NewPost() {
  const actionData = useActionData<typeof action>()
  
  return (
    <Form method="post">
      <label>
        Title
        <input name="title" required />
        {actionData?.errors?.title && <span>{actionData.errors.title}</span>}
      </label>
      <label>
        Body
        <textarea name="body" required></textarea>
        {actionData?.errors?.body && <span>{actionData.errors.body}</span>}
      </label>
      <button type="submit">Create</button>
    </Form>
  )
}
  • <Form> 컴포넌트: JS 있으면 Ajax, 없으면 브라우저 폼 제출
  • redirect()로 성공 시 네비게이션
  • 에러는 useActionData()로 받아 표시

useNavigation: 로딩 UI

import { useNavigation } from "react-router"

export function GlobalPendingUI() {
  const navigation = useNavigation()
  
  return navigation.state !== "idle" ? (
    <div className="loading-bar">Loading…</div>
  ) : null
}
  • idle: 아무 것도 없음
  • loading: loader 실행 중
  • submitting: action 제출 중

useFetcher: 네비게이션 없는 mutation

import { useFetcher } from "react-router"

function LikeButton({ postId }: { postId: number }) {
  const fetcher = useFetcher()
  const isLiking = fetcher.state !== "idle"
  
  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <button disabled={isLiking}>
        {isLiking ? "..." : "Like"}
      </button>
    </fetcher.Form>
  )
}

페이지 이동 없이 mutation 실행.

ErrorBoundary

// app/routes/blog.$slug.tsx
import { isRouteErrorResponse, useRouteError } from "react-router"

export function ErrorBoundary() {
  const error = useRouteError()
  
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    )
  }
  
  return <h1>Unknown error</h1>
}

route별 에러 경계가 자동. throw new Response(...) 또는 throw error 모두 캐치.

Meta (SEO)

import type { MetaFunction } from "react-router"

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) return [{ title: "Not Found" }]
  return [
    { title: data.post.title },
    { name: "description", content: data.post.description },
    { property: "og:title", content: data.post.title },
    { property: "og:image", content: data.post.image },
  ]
}
  • loader 데이터 기반 동적 메타
  • 타입 안전

중첩 레이아웃

// app/routes/posts.tsx
import { Outlet } from "react-router"

export default function PostsLayout() {
  return (
    <div>
      <nav>Posts Nav</nav>
      <main><Outlet /></main>
    </div>
  )
}

posts._index, posts.$id<Outlet />에 렌더됩니다.

Deferred: Streaming

import { defer, Await } from "react-router"
import { Suspense } from "react"

export async function loader() {
  const fastData = await loadFast()
  const slowData = loadSlow()   // await 안 함 → Promise
  return defer({ fastData, slowData })
}

export default function Page() {
  const { fastData, slowData } = useLoaderData<typeof loader>()
  return (
    <div>
      <h1>{fastData.title}</h1>
      <Suspense fallback={<p>Loading slow…</p>}>
        <Await resolve={slowData}>
          {(data) => <SlowSection data={data} />}
        </Await>
      </Suspense>
    </div>
  )
}

빠른 부분은 즉시 HTML 전송, 느린 부분은 스트리밍.

// app/sessions.server.ts
import { createCookieSessionStorage } from "react-router"

const { getSession, commitSession, destroySession } = createCookieSessionStorage({
  cookie: {
    name: "__session",
    secrets: [process.env.SESSION_SECRET!],
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,  // 1주
  },
})

export { getSession, commitSession, destroySession }
// loader 또는 action에서
const session = await getSession(request.headers.get("Cookie"))
const userId = session.get("userId")
if (!userId) throw redirect("/login")

Cloudflare Pages

// vite.config.ts
import { reactRouter } from "@react-router/dev/vite"
import { cloudflareDevProxy } from "@react-router/cloudflare"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [
    cloudflareDevProxy(),
    reactRouter(),
  ],
})
// app/routes/kv.tsx
import type { LoaderFunctionArgs } from "react-router"

export async function loader({ context }: LoaderFunctionArgs) {
  const value = await context.cloudflare.env.KV.get("mykey")
  return { value }
}

KV·D1·R2 바인딩 자동 타입 제공.

Optimistic UI

import { useFetcher } from "react-router"

function TodoItem({ todo }: { todo: Todo }) {
  const fetcher = useFetcher()
  const isToggling = fetcher.formData != null
  const optimisticDone = isToggling
    ? fetcher.formData.get("done") === "true"
    : todo.done
  
  return (
    <fetcher.Form method="post" action={`/todos/${todo.id}/toggle`}>
      <input type="hidden" name="done" value={String(!todo.done)} />
      <button type="submit">{optimisticDone ? "✅" : "⬜"}</button>
      <span>{todo.text}</span>
    </fetcher.Form>
  )
}

서버 응답 전에 UI를 먼저 업데이트.

리소스 프리로드

import { Link, Links } from "react-router"

<Link to="/about" prefetch="intent">About</Link>
<Link to="/blog" prefetch="viewport">Blog</Link>
<Link to="/contact" prefetch="render">Contact</Link>
  • intent: hover 시
  • viewport: 화면에 보이면
  • render: 렌더 즉시

트러블슈팅

loader가 클라이언트 네비게이션 시 호출 안 됨

  • shouldRevalidate 함수로 리벨리데이션 조건 제어
  • useFetcher.load()로 수동 페치

Form action 후 에러 메시지가 안 보임

  • action에서 json({ errors }) 반환 확인
  • useActionData()로 수신
  • 리다이렉트하면 action data 사라짐 → session flash 사용

SSR hydration mismatch

  • loader 데이터가 서버·클라이언트에서 동일해야
  • Date.now()·Math.random() 같은 비결정 값 금지

Cloudflare에서 fs 모듈 에러

  • Workers는 Node API 제한 → 서버 코드를 .server.ts로 분리
  • KV·D1·R2 바인딩 사용

쿼리 스트링이 loader 재실행 안 함

?q=... 변경은 기본적으로 revalidation 안 함. useSearchParams() + useRevalidator() 조합.

체크리스트

  • Vite + @react-router/dev 기반
  • loader·action·Form·ErrorBoundary 패턴 정착
  • useFetcher로 Ajax mutation
  • 타입 안전한 data 흐름 (useLoaderData<typeof loader>)
  • Session/Cookie로 인증
  • Prefetch 전략 설정
  • 배포 어댑터 (Cloudflare/Vercel/Node)
  • TanStack Query 조합으로 클라이언트 캐시 강화

마무리

Remix(React Router v7)는 “웹 표준에 충실하면 프레임워크가 간단해진다”는 명제를 실현한 프레임워크입니다. 2024년 통합 이후 SPA 모드까지 추가되어 범용성이 더욱 올라갔고, Shopify·Indie Hackers·OSS 커뮤니티에서 빠르게 채택됐습니다. Next.js의 RSC·Server Actions와는 다른 철학이지만 “웹 표준을 직접 쓰는 게 장기적으로 안전하다”는 관점에서는 Remix의 베팅이 더 보수적이고 안정적일 수 있습니다. 지금 Next.js로 만족하고 있다면 급하게 옮길 이유는 없지만, 새 프로젝트에서 “간단하고 표준적인 SSR”이 필요하다면 Remix를 후보 1순위로 고려하길 권장합니다.

관련 글

  • Next.js 완벽 가이드
  • React Router 완벽 가이드
  • TanStack Query v5 완벽 가이드
  • React Server Components 가이드