Void 라우팅: pages로 화면을, routes로 API를

Void: Vite 네이티브 배포 플랫폼에서 프로젝트 구조를 훑어볼 때 pages/와 routes/ 디렉토리를 잠깐 스쳐 지나갔는데요. 사실 이 두 디렉토리가 Void로 앱을 만드는 거의 모든 것을 담당합니다. 화면을 그리는 일도, API를 여는 일도 결국 “어떤 파일을 어디에 두느냐”로 결정되거든요.
이번 글에서는 Void의 라우팅을 제대로 파헤쳐볼게요. 파일 하나가 어떻게 URL이 되는지, 서버에서 가져온 데이터가 어떻게 화면 컴포넌트로 흘러가는지, API 핸들러는 어떻게 쓰고 입력 검증과 미들웨어는 어떻게 거는지까지 차근차근 살펴보겠습니다.
화면은 pages, API는 routes
Void의 라우팅은 두 갈래로 나뉩니다. 사용자에게 보여줄 화면은 pages/에, 데이터를 주고받는 API는 routes/에 둡니다. 둘 다 파일 기반(file-based)이라 파일의 위치가 그대로 URL 경로가 돼요.
둘의 성격은 꽤 다릅니다. pages/는 Inertia.js에서 영감을 받았는데요. 핵심 아이디어는 단순합니다. 서버가 데이터를 반환하면 컴포넌트가 그걸 props로 받아 화면을 그린다는 거예요. 따로 API를 호출하거나 로딩 상태를 들고 다닐 필요가 없습니다. 반면 routes/는 Hono 위에서 동작하는 순수한 API 레이어라, REST 엔드포인트를 만들기에 어울립니다.
그래서 같은 프로젝트 안에서 “회원 프로필 화면”은 pages에, “외부 앱이 호출할 회원 조회 API”는 routes에 두는 식으로 역할이 자연스럽게 갈립니다. 이제 각각을 들여다볼게요.
파일이 곧 URL이 되는 pages
먼저 화면 쪽입니다. pages/ 안에 파일을 만들면 그 위치가 URL이 됩니다.
pages/
├─ layout.tsx # 모든 페이지를 감싸는 루트 레이아웃
├─ index.tsx # → /
├─ index.server.ts # / 의 데이터 로더
├─ about.tsx # → /about
├─ users/
│ ├─ layout.tsx # /users/* 전용 레이아웃
│ ├─ [id].tsx # → /users/:id
│ └─ [id].server.ts # /users/:id 의 로더
└─ blog/
└─ hello.md # → /blog/hello
규칙은 직관적이에요.
about.tsx: 평범한 파일은/about이 됩니다.about/index.tsx: 디렉토리 안의index도 똑같이/about이 됩니다.[id].tsx: 대괄호로 감싸면/users/:id같은 동적 경로가 됩니다.[...param].tsx: 점 세 개를 붙이면 여러 경로 조각을 한꺼번에 받는 catch-all 라우트가 됩니다.(group)/: 괄호로 묶으면 코드를 디렉토리로 정리하되 URL에는 영향을 주지 않습니다.hello.md:.md파일을 두면 마크다운이 그대로 페이지가 됩니다.
별도의 라우트 설정 파일을 작성하거나 경로를 손으로 등록할 일이 없습니다. 파일을 옮기면 URL이 따라 바뀌는 셈이죠.
페이지와 서버 로더
Void 라우팅에서 가장 특징적인 부분이 바로 이 .tsx와 .server.ts의 짝꿍 구조예요. 화면 컴포넌트(.tsx) 옆에 같은 이름의 .server.ts를 두면, 그게 그 페이지의 서버 로더가 됩니다.
import { defineHandler } from "void";
export const loader = defineHandler(async ({ params }) => {
const user = await db.users.findById(params.id);
const posts = await db.posts.findByUserId(params.id);
return { user, posts }; // 이 객체가 그대로 컴포넌트의 props가 됩니다
});
export default function UserPage({ user, posts }) {
return (
<div>
<h1>{user.name}</h1>
<p>글 {posts.length}개</p>
</div>
);
}
로더는 GET 요청이 들어올 때 서버에서 실행되고, 반환한 객체가 그대로 페이지 컴포넌트의 props로 넘어갑니다. 컴포넌트 안에서 직접 fetch를 부르거나 로딩 상태를 관리할 필요가 없어요. 데이터를 가져오는 일과 화면을 그리는 일이 파일 두 개로 깔끔하게 분리되는 거죠.
게다가 로더의 반환 타입이 컴포넌트 props의 타입을 자동으로 결정합니다. 로더에서 user에 필드를 하나 추가하면 컴포넌트 쪽 props 타입에도 바로 반영되기 때문에, 서버에서 화면까지 타입이 끊기지 않고 이어집니다. 데이터 모양이 어긋나면 빌드 단계에서 걸리고요.
무거운 데이터 하나 때문에 화면 전체가 늦어지는 걸 막고 싶을 때는 defer()를 씁니다. 외부 API 호출이나 AI 추론처럼 느린 작업을 defer()로 감싸면, 빠른 데이터는 먼저 화면에 그리고 느린 값은 준비되는 대로 스트리밍해줘요. React에서는 Suspense와 use()로, Vue, Svelte, Solid에서는 { loading, value, error } 상태로 받아 처리합니다.
데이터를 읽기만 하는 게 아니라 폼 제출이나 변경 처리가 필요하면 loader 대신 action을 내보냅니다. action은 POST, PUT, DELETE 요청을 받아 처리해요. 폼과 액션을 본격적으로 다루는 법은 Void 폼과 액션에서 따로 정리했습니다. 또 화면을 서버에서 렌더링하지 않고 클라이언트에서만 그리고 싶다면 ssr을 false로 내보내면 됩니다.
동적 라우트와 레이아웃
[id].tsx 같은 동적 라우트에서 경로 값을 꺼내는 방법은 두 가지예요. 서버 로더에서는 위 예제처럼 params.id로 바로 접근하고, 컴포넌트 안에서는 useParams 훅을 씁니다.
import { useParams } from "@void/react";
export default function PostPage() {
const { id } = useParams<{ id: string }>();
return <h1>{id}번 글</h1>;
}
여러 페이지가 공통으로 쓰는 골격은 layout.tsx로 묶습니다. pages/layout.tsx는 모든 페이지를, pages/users/layout.tsx는 /users/ 아래 페이지만 감싸요. 레이아웃은 중첩되기 때문에 /users/3 페이지는 루트 레이아웃과 users 레이아웃을 둘 다 거쳐 렌더링됩니다. 헤더나 사이드바처럼 반복되는 UI를 한곳에 모아두기 좋죠.
SPA처럼 움직이는 클라이언트 내비게이션
Void 페이지는 첫 로드에선 서버 렌더링된 HTML을 받아 하이드레이션하지만, 그 뒤의 페이지 이동은 단일 페이지 앱(SPA)처럼 동작합니다. <Link> 컴포넌트로 이동하면 전체 페이지를 새로 받지 않고, 컴포넌트 이름과 props만 담긴 JSON을 가져와 화면만 다시 그려요.
import { Link } from "@void/react";
<Link href="/users">사용자 목록</Link>;
<Link href={`/users/${id}`}>자세히</Link>;
코드로 이동해야 할 때는 useRouter를 씁니다.
const router = useRouter();
router.visit("/users"); // 다른 페이지로 이동
router.refresh(); // 현재 페이지의 로더만 다시 실행
뒤로나 앞으로 가기를 할 때 스크롤 위치도 알아서 보존됩니다. 첫 로드의 서버 렌더링 성능과 이후 전환의 매끄러움을 둘 다 챙기는 셈이에요. 우리가 평소 React 앱에서 직접 만들어야 했던 라우터와 데이터 페칭 계층이 라우팅 규칙 안에 녹아 있는 거죠.
API는 routes에
이제 API 쪽입니다. routes/ 디렉토리도 파일 기반이고, 내부적으로는 Hono가 HTTP를 처리해요.
routes/
└─ api/
├─ hello.ts # → /api/hello
├─ users/
│ ├─ index.ts # → /api/users
│ └─ [id].ts # → /api/users/:id
└─ search/
└─ [...query].ts # → /api/search/* (catch-all)
핸들러는 HTTP 메서드 이름(GET, POST, PUT, DELETE)을 named export로 내보내고, defineHandler로 감쌉니다.
import { defineHandler } from "void";
export const GET = defineHandler((c) => {
return { message: "안녕하세요!", now: Date.now() };
});
한 파일에 여러 메서드를 둘 수도 있어요.
import { defineHandler } from "void";
import { db } from "void/db";
import { users } from "@schema";
export const GET = defineHandler(async () => {
return await db.select().from(users); // 객체와 배열은 자동으로 JSON 응답
});
export const POST = defineHandler(async (c) => {
const body = await c.req.json();
await db.insert(users).values({ name: body.name });
return { created: true };
});
defineHandler에 넘어오는 c는 Hono의 Context인데요. c.req.json()으로 요청 본문을, c.req.param("id")로 동적 경로 값을, c.req.query()로 쿼리 문자열을 꺼냅니다. c.env로는 D1이나 KV 같은 Cloudflare 바인딩에 타입까지 붙어 접근할 수 있고요.
한 가지 덧붙이면, 이렇게 c.env로 바인딩을 꺼내는 건 Hono 위에서 도는 Void 라우트 핸들러에서 통하는 방식이에요. TanStack Start나 React Router를 그대로 얹는 프레임워크 모드에는 Hono Context가 없어서, 그쪽에서는 cloudflare:workers 모듈에서 바인딩을 직접 import해 같은 D1·KV에 접근합니다. 결국 바인딩에 닿는 길이 실행 모드에 따라 두 갈래로 나뉘는 셈이죠.
반환값은 알아서 변환됩니다. 객체나 배열은 JSON으로, 문자열은 HTML로, null이나 undefined는 204 No Content로 나가고, 직접 만든 Response 객체는 그대로 전달돼요. 그래서 간단한 엔드포인트는 return { ... } 한 줄이면 끝납니다.
입력 검증과 미들웨어
API를 열었으면 들어오는 데이터를 검증해야겠죠. defineHandler.withValidator()로 본문과 쿼리, 파라미터를 검사할 수 있는데, Void는 Drizzle 스키마에서 검증기를 바로 뽑아내는 방식을 권합니다. 스키마에서 createInsertSchema로 검증기를 만드는 자세한 과정은 Void 데이터베이스에서 다룹니다.
import { defineHandler } from "void";
import { db } from "void/db";
import { users, insertUserSchema } from "@schema";
export const POST = defineHandler.withValidator({
body: insertUserSchema, // Drizzle 스키마에서 파생한 검증기
})(async (c, { body }) => {
const [created] = await db.insert(users).values(body).returning();
return created;
});
스키마가 따로 없는 엔드포인트라면 Zod나 Valibot, ArkType 같은 Standard Schema 호환 라이브러리를 써도 됩니다.
import * as v from "valibot";
import { defineHandler } from "void";
export const POST = defineHandler.withValidator({
body: v.object({
query: v.pipe(v.string(), v.minLength(1)),
limit: v.optional(v.pipe(v.number(), v.maxValue(100)), 10),
}),
})(async (c, { body }) => {
return search(body.query, body.limit);
});
검증에 실패하면 자동으로 400 응답에 어떤 필드가 잘못됐는지 담아 돌려줍니다. 직접 if (!body.query) return ... 같은 코드를 짤 필요가 없어요. 게다가 이 검증 스키마는 타입 안전한 fetch 클라이언트에도 그대로 쓰여서, API를 호출하는 쪽에서도 잘못된 요청이 타입 단계에서 걸립니다.
모든 요청에 공통으로 거는 처리는 미들웨어로 뺍니다. 전역 미들웨어는 middleware/ 디렉토리에 두고, 파일명 앞에 숫자를 붙여 실행 순서를 정해요.
import { defineMiddleware } from "void";
export default defineMiddleware(async (c, next) => {
console.log(c.req.method, c.req.path);
await next();
});
특정 라우트에만 거는 미들웨어는 defineHandler에 핸들러보다 앞으로 넘기면 됩니다. CORS나 응답 시간 측정처럼 일부 엔드포인트에만 필요한 처리를 이렇게 붙일 수 있어요. 미들웨어가 c.set()으로 심어둔 값은 뒤따르는 핸들러에서 타입과 함께 꺼내 쓸 수 있고요.
응답 헤더는 void.json에서
화면과 API를 다뤘으니, 그 응답에 HTTP 헤더를 붙이는 법도 짚고 갈게요. 보안 헤더를 얹거나 캐시 정책을 바꾸고 싶을 때, Void는 코드가 아니라 void.json의 routing.headers에서 선언적으로 정합니다. 키는 URL 패턴, 값은 "이름: 값" 문자열의 배열이에요.
{
"routing": {
"headers": {
"/*": [
"X-Frame-Options: DENY",
"X-Content-Type-Options: nosniff",
"Referrer-Policy: strict-origin-when-cross-origin"
],
"/*.html": ["Cache-Control: public, max-age=300"]
}
}
}
패턴 키는 /로 시작하고 *는 슬래시를 포함한 아무 문자열이나 매칭합니다. 여러 규칙이 겹치면 마지막에 매칭된 규칙이 이겨요. 그래서 넓은 /* 규칙을 먼저 깔고 특정 경로에서 덮어쓰는 식으로 조합하면 됩니다.
이 규칙은 정적 파일, SSR 페이지, API 응답은 물론 해시가 붙은 불변(immutable) 에셋에까지 적용됩니다. 사실 이 마지막 부분이 0.10.2에서 새로 열린 지점인데요, 해시 에셋에도 헤더를 붙일 수 있게 되면서 cross-origin isolation이 가능해졌어요. SharedArrayBuffer나 정밀 타이머를 쓰려면 문서에 Cross-Origin-Embedder-Policy가 필요한데, 이때 워커나 wasm 같은 해시 에셋에도 Cross-Origin-Resource-Policy가 붙어 있어야 require-corp 문서가 그 모듈을 불러올 수 있거든요.
{
"routing": {
"headers": {
"/*": [
"Cross-Origin-Opener-Policy: same-origin",
"Cross-Origin-Embedder-Policy: require-corp"
]
}
}
}
다만 해시 에셋에서는 규칙이 덧붙이는(additive) 방식이라 안전장치가 하나 있어요. COOP/COEP/CORP나 보안 헤더는 마음대로 추가할 수 있지만, Cache-Control이나 Content-Type처럼 플랫폼이 관리하는 헤더는 덮어쓸 수 없습니다. 불변 에셋이 엉뚱한 캐시나 타입으로 오염되는 걸 막기 위해서죠. 재미있는 건, 해시 에셋의 규칙은 캐시 히트에도 매 요청마다 다시 적용돼서, 콘텐츠 해시가 그대로여도 헤더 규칙만 바꾸면 곧바로 반영된다는 점이에요.
한 가지 더, Set-Cookie와 Clear-Site-Data는 규칙으로 아예 설정할 수 없습니다. 공유 도메인인 *.void.app에서는 등록 도메인이 void.app이라, 한 프로젝트가 심은 쿠키가 다른 프로젝트의 서브도메인까지 새어 나가거든요. 테넌트 경계를 지키려고 이 둘은 규칙 출력에서 걸러냅니다. 참고로 SvelteKit이나 Nuxt, Astro가 만들어내는 _headers 파일도 Void가 배포 때 자동으로 읽어 병합하니, 프레임워크가 깔아둔 캐시 규칙 위에 void.json으로 얹기만 하면 됩니다.
그래서 언제 무엇을 쓰나
정리하면 기준은 간단합니다. 사람이 볼 화면이면 pages, 데이터만 주고받는 API면 routes예요.
pages는 로더로 데이터를 받아 컴포넌트로 렌더링하고, 폼 액션과 레이아웃, 클라이언트 내비게이션까지 화면에 필요한 풀스택 파이프라인을 담당합니다. 화면을 그리면서 그 화면에 필요한 데이터를 함께 가져오는 경우라면, 굳이 별도 API를 만들지 말고 페이지 로더만 써도 충분합니다.
반대로 routes는 Hono 기반의 순수 HTTP 레이어라, 모바일 앱이나 외부 서비스가 호출할 REST API, 웹훅 수신, 백그라운드 작업처럼 화면과 무관하게 재사용되는 엔드포인트에 어울립니다. “이 데이터를 우리 화면 말고 다른 곳도 쓰는가?”를 떠올려 보면 어느 쪽에 둘지 금방 정해져요.
마치며
Void의 라우팅은 파일을 어디에 두느냐만으로 화면과 API가 모두 결정됩니다. 거기에 서버 로더부터 클라이언트까지 타입이 끊기지 않으니, 단순하면서도 안전하고요. pages의 로더-props 패턴과 routes의 defineHandler만 익히고 나면, 대부분의 풀스택 기능을 별도 보일러플레이트 없이 만들 수 있어요.
여기까지 만든 앱을 실제로 배포하는 과정은 Void: Vite 네이티브 배포 플랫폼에서, 기존 서비스를 Void로 옮긴 실전 기록은 Void로 프로덕션 앱 이전하기에서 이어집니다. 서버 라우팅의 토대가 되는 Hono가 궁금하다면 Hono 가이드도 함께 보면 좋고요.
더 자세한 내용은 Void 공식 라우팅 문서를 참고하세요.
This work is licensed under CC BY 4.0