Remix & React Router v7 가이드 — 웹 표준·SSR·Progressive
이 글의 핵심
Remix는 브라우저의 기본 Form과 HTTP 요청을 최대한 그대로 쓰는 방향을 택했고, 이 설계가 React Router v7로 합쳐졌습니다. Form과 useFetcher 중 무엇을 쓸지, 쿼리 스트링이 바뀌었는데 화면이 갱신되지 않는 문제, hydration mismatch나 Cloudflare 환경의 fs 모듈 에러 같은 실제 막히는 지점을 중심으로 설명합니다.
설치
pnpm create react-router@latest my-app
# 기본 템플릿 외에 --template 옵션으로 배포 대상별 템플릿(예: Cloudflare)을 고를 수 있음
cd my-app && pnpm dev
React Router v7에는 사용 방식이 세 가지 있습니다. 예전 React Router처럼 <BrowserRouter>로 감싸서 쓰는 선언적 모드, createBrowserRouter에 loader까지 등록하는 데이터 모드, 그리고 이 글이 다루는 프레임워크 모드입니다. 프레임워크 모드가 사실상 예전의 Remix이며, Vite 플러그인(@react-router/dev)이 라우트 파일을 읽어 서버 렌더링, 코드 분할, 타입 생성을 담당합니다. 기존 Remix 2 프로젝트는 @remix-run/* 패키지를 react-router와 @react-router/*로 바꾸는 방식으로 옮기며, Remix 2에서 “future flag”를 모두 켜 둔 프로젝트라면 변경 폭이 작습니다.
라우팅
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/ 디렉터리에.
이 “flat routes” 규칙은 폴더 깊이가 아니라 파일 이름의 점(.) 으로 URL 계층을 표현합니다. posts.$id.tsx는 posts/ 폴더 안이 아니라 posts.tsx와 같은 디렉터리에 있고, 이름이 posts.로 시작하기 때문에 posts.tsx의 자식 라우트가 됩니다. $는 동적 세그먼트(:id), _index는 부모 경로 자체(/blog)를 뜻합니다. 부모 레이아웃 없이 URL만 중첩하고 싶다면 posts_.$id.tsx처럼 밑줄을 붙여 레이아웃 중첩에서 빠져나옵니다.
한 가지 주의할 점은 React Router v7의 기본값이 파일 기반 라우팅이 아니라는 것입니다. 프레임워크 모드는 app/routes.ts에 route("about", "routes/about.tsx")처럼 라우트를 코드로 선언하는 것이 기본이고, 위와 같은 파일 이름 규칙을 쓰려면 @react-router/fs-routes 패키지의 flatRoutes()를 routes.ts에서 내보내야 합니다. Remix 2의 글을 보고 파일만 만들었는데 404가 난다면 대부분 이 설정이 빠진 경우입니다.
Loader: 데이터 페칭
// app/routes/blog.$slug.tsx
import { type LoaderFunctionArgs, useLoaderData } 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 { post } // v7: json() 없이 객체를 그대로 반환
}
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>)
loader는 항상 서버에서만 실행되는 함수입니다. 첫 요청에서는 서버가 loader를 실행한 결과로 HTML을 렌더링하고, 이후 클라이언트에서 링크를 눌러 이동하면 라우터가 서버에 loader 결과만 요청해 받아 옵니다. 그래서 loader 안에서는 DB 클라이언트나 비밀 키를 마음 놓고 써도 브라우저 번들에 들어가지 않습니다. 컴포넌트에서 useEffect로 데이터를 불러오는 방식과 비교하면, 로딩 스피너가 먼저 뜨고 나중에 데이터가 채워지는 “폭포수” 요청이 없고, 중첩 라우트의 loader들이 병렬로 실행된다는 차이가 있습니다.
Remix 시절 코드에서 흔히 보던 json()과 defer()는 React Router v7에서 사용하지 않는 방향으로 바뀌었습니다. v7은 loader가 반환한 객체를 그대로 직렬화하는데, Date, Map, Promise 같은 값도 원래 타입을 유지해 전달되므로(turbo-stream 직렬화) 예전처럼 Date가 문자열로 바뀌는 문제가 사라졌습니다. 상태 코드나 헤더가 필요하면 data(값, { status: 404 }) 유틸리티를 씁니다. 404를 throw new Response(...)로 던지면 아래의 ErrorBoundary로 넘어가고, 반환하면 정상 렌더링으로 이어진다는 차이도 기억해 두세요. 또 v7은 라우트마다 ./+types/blog.$slug 타입 파일을 자동 생성해 Route.LoaderArgs, Route.ComponentProps처럼 쓸 수 있으므로, typeof loader 제네릭보다 정확한 타입(URL 파라미터 이름까지)을 얻을 수 있습니다.
예제의 dangerouslySetInnerHTML은 DB에 저장된 HTML을 그대로 넣으므로, 그 HTML이 사용자 입력에서 온 것이라면 저장 전이나 렌더링 전에 반드시 sanitize해야 합니다. 그렇지 않으면 글 본문에 삽입된 <script>가 다른 사용자의 브라우저에서 실행되는 XSS가 됩니다.
Action: Mutation
// app/routes/posts.new.tsx
import { type ActionFunctionArgs, Form, data, redirect, useActionData } 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 values = Object.fromEntries(formData) // data() 유틸과 이름이 겹치지 않게
const result = schema.safeParse(values)
if (!result.success) {
return data({ 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()로 받아 표시
이 흐름은 브라우저의 기본 폼 제출과 구조가 같습니다. 폼이 POST되면 서버의 action이 실행되고, 성공하면 리다이렉트, 실패하면 에러 데이터를 돌려줍니다. 차이는 JavaScript가 로드된 뒤에는 <Form>이 페이지 전체를 새로 고치지 않고 fetch로 제출한 뒤 결과만 반영한다는 점입니다. action이 끝나면 라우터가 현재 화면의 모든 loader를 자동으로 다시 실행하므로, 글을 추가한 뒤 목록을 수동으로 갱신하는 코드를 따로 쓸 필요가 없습니다. 클라이언트 캐시를 직접 관리하는 SPA에서 가장 버그가 많이 나는 “변경 후 화면 동기화”를 프레임워크가 떠맡는 셈입니다.
Object.fromEntries(formData)는 간단하지만, 같은 이름의 필드가 여러 개(체크박스 목록 등)면 마지막 값만 남는다는 한계가 있습니다. 그런 필드는 formData.getAll("tags")로 따로 읽어야 합니다. 또 zod 검증에 실패했을 때 data(..., { status: 400 })로 에러를 반환하면 사용자가 입력한 값이 폼에 그대로 남아 있지만, JavaScript 없이 제출한 경우에는 페이지가 새로 렌더링되면서 입력값이 사라지므로 defaultValue로 이전 값을 채워 주는 처리가 필요합니다. 성공 후 redirect하는 것은 새로고침 시 폼이 다시 제출되는 문제(POST 재전송)를 막는 고전적인 PRG(Post/Redirect/Get) 패턴이기도 합니다.
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 제출 중
useNavigation은 페이지 이동과 <Form> 제출 전체의 상태를 하나로 보여 줍니다. 그래서 루트 레이아웃에 두면 앱 전체의 진행 표시줄 역할을 하고, navigation.formData로 지금 제출 중인 값을 읽어 버튼 문구를 “저장 중…”으로 바꿀 수도 있습니다. action이 끝난 뒤 loader가 다시 실행되는 동안에는 submitting 다음에 loading 상태를 거치므로, “제출 완료” 시점을 판단할 때는 두 상태를 모두 고려해야 합니다. 빠른 네트워크에서는 로딩 표시가 한 번 번쩍이고 사라져 오히려 거슬릴 수 있어서, 일정 시간(예: 200ms) 이상 걸릴 때만 보여 주는 지연 처리를 흔히 추가합니다.
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 실행.
useFetcher는 좋아요 버튼, 장바구니 담기, 인라인 편집처럼 URL이 바뀌면 안 되는 상호작용에 씁니다. fetcher마다 자기 상태를 따로 가지므로 목록의 좋아요 버튼 열 개가 각각 독립적으로 로딩 상태를 표시할 수 있고, 전역 useNavigation에는 영향을 주지 않습니다. fetcher.Form의 action은 다른 라우트 파일(posts.$id.like.tsx)의 action을 가리키며, 그 라우트에 컴포넌트가 없어도 되는 “리소스 라우트”로 만들 수 있습니다. fetcher의 action이 끝나도 페이지의 loader들이 다시 실행되므로, 좋아요 수가 자동으로 갱신됩니다. 매번 전체 loader가 다시 도는 것이 부담이라면 라우트에 shouldRevalidate를 정의해 필요한 경우에만 갱신하게 조절할 수 있습니다.
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 모두 캐치.
에러 경계가 라우트 단위라는 점이 이 설계의 핵심입니다. blog.$slug.tsx의 loader가 실패하면 그 라우트 자리에만 에러 화면이 나오고, 바깥 레이아웃(헤더, 사이드바)은 그대로 남습니다. 해당 라우트에 ErrorBoundary가 없으면 가장 가까운 부모의 것으로 올라가며, 최종적으로 root.tsx의 경계가 받습니다. isRouteErrorResponse는 의도적으로 던진 Response(404, 403 등)와 예상치 못한 예외를 구분해 줍니다. 전자는 “글이 없습니다” 같은 안내를, 후자는 일반적인 오류 메시지를 보여 주는 식으로 나누는 것이 좋습니다. 운영 환경에서는 서버 예외의 메시지와 스택이 클라이언트로 전달되지 않도록 마스킹되므로, 실제 원인은 서버 로그나 entry.server.tsx의 handleError에서 수집해야 합니다. error.data가 문자열이 아니라 객체일 수도 있으니 그대로 JSX에 넣기 전에 형태를 확인하는 편이 안전합니다.
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 데이터 기반 동적 메타
- 타입 안전
meta는 loader가 실패해 data가 없을 때도 호출되므로 첫 줄의 if (!data) 처리가 필요합니다. 이 부분이 없으면 404 페이지에서 Cannot read properties of undefined (reading 'post') 에러가 나서 에러 경계까지 망가지는 일이 생깁니다. 메타 태그를 서버에서 렌더링된 HTML에 넣는 것은 SEO와 소셜 미리보기에 중요한데, 검색 엔진이나 메신저의 링크 미리보기 봇은 JavaScript를 실행하지 않거나 제한적으로만 실행하기 때문입니다. React 19부터는 컴포넌트 안에서 <title>과 <meta>를 직접 렌더링해도 <head>로 옮겨 주므로, 두 방식 중 팀이 한 가지로 통일하는 것이 좋습니다.
중첩 레이아웃
// 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 />에 렌더됩니다.
중첩 레이아웃의 이점은 바뀌는 부분만 다시 불러온다는 것입니다. /posts/1에서 /posts/2로 이동하면 posts.tsx 레이아웃은 그대로 두고 posts.$id.tsx의 loader와 컴포넌트만 갱신됩니다. 레이아웃의 loader(예: 게시판 목록)는 기본적으로 다시 실행되지 않고, URL 파라미터나 검색어가 바뀌었을 때, 또는 action이 실행된 뒤에만 다시 실행됩니다. 이 규칙이 “왜 이 loader는 다시 안 도는가”를 이해하는 출발점입니다.
Deferred: Streaming
import { Await, useLoaderData } from "react-router"
import { Suspense } from "react"
export async function loader() {
const fastData = await loadFast()
const slowData = loadSlow() // await 안 함 → Promise
return { fastData, slowData } // v7: defer() 없이 Promise를 그대로 반환
}
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 전송, 느린 부분은 스트리밍.
await하지 않은 Promise를 반환하면, 서버는 fastData로 HTML을 먼저 보내고 연결을 열어 둔 채 slowData가 끝나는 대로 같은 응답 스트림에 이어서 보냅니다. 첫 화면이 가장 느린 쿼리를 기다리지 않아도 된다는 것이 장점이며, 추천 목록이나 댓글처럼 중요도가 낮은 영역에 적합합니다. loadSlow()가 reject되면 <Await>의 errorElement로 처리할 수 있으므로, 이 부분의 실패가 페이지 전체를 에러 경계로 보내지 않게 하려면 errorElement를 함께 지정하는 것이 좋습니다.
스트리밍이 로컬에서는 되는데 배포하면 한꺼번에 도착한다면, 중간의 프록시나 CDN이 응답을 버퍼링하고 있을 가능성이 높습니다. Nginx의 proxy_buffering, 일부 서버리스 플랫폼의 응답 버퍼링, 압축 미들웨어가 대표적인 원인입니다. 또 SEO상 중요한 콘텐츠를 스트리밍 쪽에 두면 초기 HTML에 포함되지 않을 수 있으므로, 검색에 노출되어야 하는 본문은 await해서 보내는 편이 안전합니다.
Cookie / Session
// 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")
쿠키 세션은 세션 데이터 자체를 서명된 쿠키에 담기 때문에 서버에 별도 저장소가 필요 없고, 서버리스·엣지 환경에서도 그대로 동작합니다. secrets는 쿠키 위조를 막는 서명 키이며, 배열로 받는 이유는 키를 교체할 때 새 키를 맨 앞에 추가하고 옛 키를 뒤에 남겨 두면 기존 사용자의 쿠키도 계속 검증할 수 있기 때문입니다. 서명은 위조를 막을 뿐 내용을 암호화하지는 않으므로 쿠키에 민감한 정보를 넣으면 안 되고, 쿠키 크기 제한(보통 약 4KB) 때문에 큰 데이터도 담을 수 없습니다. 그런 경우에는 세션 ID만 쿠키에 두고 데이터는 DB나 KV에 저장하는 createSessionStorage 계열을 씁니다.
세션을 수정했다면 응답에 Set-Cookie: await commitSession(session) 헤더를 붙여야 반영된다는 점을 빠뜨리기 쉽습니다. session.set()만 호출하고 헤더를 붙이지 않으면 아무 에러 없이 로그인이 유지되지 않는 증상이 나타납니다. 또 Cloudflare Workers에서는 process.env가 기본으로 존재하지 않으므로, SESSION_SECRET은 loader의 context.cloudflare.env에서 읽어 세션 스토리지를 요청마다 만들어야 합니다. loader에서 throw redirect(...)를 쓰면 이후 코드가 실행되지 않고 바로 리다이렉트 응답이 나가므로, 인증 가드를 한 줄로 쓸 수 있습니다.
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 바인딩 자동 타입 제공.
cloudflareDevProxy()는 로컬 vite dev에서 Wrangler의 플랫폼 에뮬레이션을 연결해, 개발 중에도 context.cloudflare.env로 KV·D1 바인딩에 접근할 수 있게 해 줍니다. 반드시 reactRouter() 플러그인보다 앞에 두어야 하며, 순서가 바뀌면 개발 서버에서 context.cloudflare가 undefined가 됩니다. 최근 템플릿은 Cloudflare 공식 Vite 플러그인(@cloudflare/vite-plugin)을 쓰는 방식으로 바뀌고 있으므로, 새로 시작한다면 create-react-router의 Cloudflare 템플릿이 생성하는 설정을 기준으로 삼는 것이 좋습니다. “자동 타입”은 wrangler types 명령으로 wrangler.toml(또는 wrangler.jsonc)의 바인딩 정의에서 타입 파일을 생성해 주는 것을 말하며, 바인딩을 추가한 뒤 이 명령을 다시 실행하지 않으면 타입이 맞지 않습니다.
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를 먼저 업데이트.
낙관적 UI의 핵심은 별도의 상태를 만들지 않는다는 점입니다. fetcher.formData에는 지금 제출 중인 값이 들어 있으므로, 제출 중이면 그 값을, 끝나면 loader가 새로 가져온 todo.done을 보여 주기만 하면 됩니다. 서버 요청이 실패하면 fetcher.formData가 사라지면서 자연스럽게 원래 값으로 되돌아가고, 성공하면 loader 재실행으로 서버의 실제 값이 들어옵니다. useState로 낙관적 상태를 따로 관리하면 서버 값과 어긋나는 순간을 직접 처리해야 하는데, 이 방식은 그 동기화 문제를 피합니다. 사용자가 버튼을 빠르게 여러 번 누르는 경우에는 요청 순서가 뒤바뀔 수 있으므로, 서버 쪽에서 “토글”보다 “이 값으로 설정”하는 멱등한 요청(done=true)을 받는 것이 안전합니다. 예제가 !todo.done을 hidden input으로 보내는 이유가 이것입니다.
리소스 프리로드
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: 렌더 즉시
프리페치는 링크 대상 라우트의 JavaScript 청크와 loader 데이터를 미리 받아 두어, 클릭했을 때 거의 즉시 화면이 바뀌게 합니다. intent는 마우스를 올리거나 포커스를 줄 때 시작하므로 가장 무난한 기본값입니다. viewport와 render는 효과가 크지만, 목록 페이지에서 링크 수십 개에 render를 걸면 페이지를 여는 순간 loader 요청이 수십 개 나가서 서버 부하와 사용자 데이터 사용량이 늘어납니다. loader 데이터까지 미리 받는다는 것은 DB 쿼리도 미리 실행된다는 뜻이므로, 조회수 증가처럼 부수 효과가 있는 로직을 loader에 두지 않는 것도 중요합니다.
트러블슈팅
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 바인딩 사용
쿼리 스트링을 바꿨는데 화면이 갱신되지 않음
<Link>, <Form method="get">, setSearchParams()로 ?q=...를 바꾸면 라우터가 해당 loader를 다시 실행합니다(검색어 변경은 기본 revalidation 대상). 그런데도 화면이 그대로라면 흔한 원인은 두 가지입니다. 하나는 window.history.pushState처럼 라우터를 거치지 않고 URL만 바꾼 경우로, 라우터가 변경을 모르므로 loader가 돌지 않습니다. 다른 하나는 컴포넌트에서 const [items, setItems] = useState(loaderData.items)처럼 loader 데이터로 초깃값만 설정한 경우입니다. loader는 새 데이터를 가져왔지만 useState 초깃값은 첫 렌더링에서만 쓰이므로 화면이 바뀌지 않습니다. loader 데이터를 상태로 복사하지 말고 useLoaderData() 결과를 직접 렌더링하는 것이 해결책입니다. 라우트에 shouldRevalidate를 정의해 false를 반환하고 있지 않은지도 확인해 보세요.
도입 전에 판단할 것
프레임워크 모드는 서버가 있는 앱을 전제로 합니다. loader와 action이 서버에서 돌기 때문에 Node.js 서버나 Workers 같은 실행 환경이 필요하고, 정적 호스팅만 가능한 환경이라면 ssr: false로 설정해 SPA 모드로 쓰거나 빌드 시점에 페이지를 미리 렌더링하는 prerender 옵션을 써야 합니다. 이미 TanStack Query로 클라이언트 캐시를 정교하게 관리하는 SPA를 옮긴다면, loader의 자동 revalidation과 기존 캐시 정책이 겹치지 않도록 한쪽으로 역할을 정리하는 것이 먼저입니다. 반대로 새로 시작하는 폼 중심 서비스(관리자 화면, 게시판, 전자상거래)라면 “loader로 읽고, Form으로 쓰고, 끝나면 자동으로 다시 읽는” 흐름이 코드량을 크게 줄여 줍니다.
마무리
Remix(React Router v7)는 “웹 표준에 충실하면 프레임워크가 간단해진다”는 명제를 실현한 프레임워크입니다. 2024년 통합 이후 SPA 모드까지 추가되어 범용성이 더욱 올라갔고, Remix를 인수한 Shopify가 자사 서비스와 Hydrogen에 사용하고 있습니다. Next.js의 RSC·Server Actions와는 다른 철학이지만 “웹 표준을 직접 쓰는 게 장기적으로 안전하다”는 관점에서는 Remix의 베팅이 더 보수적이고 안정적일 수 있습니다. 지금 Next.js로 만족하고 있다면 급하게 옮길 이유는 없지만, 새 프로젝트에서 “간단하고 표준적인 SSR”이 필요하다면 Remix를 후보 1순위로 고려하길 권장합니다.
자주 묻는 질문 (FAQ)
Q. Form과 useFetcher는 각각 언제 써야 하나요?
A. Form은 제출 후 페이지 이동(네비게이션)이 자연스러운 경우, 예를 들어 글 작성 후 상세 페이지로 가는 흐름에 맞으며 진행 상태는 전역 useNavigation으로 봅니다. 좋아요 버튼처럼 현재 화면에 머문 채 mutation만 실행해야 한다면 useFetcher의 fetcher.Form을 씁니다. fetcher는 인스턴스마다 자기 state를 가지므로 버튼별로 로딩 상태를 따로 표시할 수 있고, 한 화면에 독립적인 mutation이 여러 개 있을 때 특히 유용합니다.