TanStack Start: 타입 안전한 풀스택 React 프레임워크
React로 풀스택 앱을 만들 때 어떤 프레임워크를 쓰시나요? 아마 Next.js를 먼저 떠올리실 텐데요. Next.js가 사실상 표준처럼 자리 잡긴 했지만 쓰다 보면 아쉬운 점도 있습니다. 타입 안전성이 중간중간 끊긴다거나, 캐싱 동작이 예상과 다르게 돌아간다거나, 배포할 때 특정 플랫폼에 묶이는 느낌이 든다거나요.
그런 분들에게 TanStack Start가 반가울 겁니다. TanStack Query와 TanStack Table로 유명한 TanStack 생태계에서 나온 풀스택 React 프레임워크인데요. “엔드투엔드 타입 안전성”을 내세우면서 개발자가 원하는 대로 제어할 수 있는 자유도를 줍니다.
이번 포스팅에서는 TanStack Start가 어떤 녀석인지, Next.js와 뭐가 다른지 살펴보고 프로젝트 생성부터 서버 함수, 미들웨어까지 예제로 훑어보겠습니다.
TanStack Start가 뭔가요?
TanStack Start는 TanStack Router 위에 서버 기능을 얹은 풀스택 React 프레임워크입니다. 빌드 도구로 Vite를 쓰고, 현재 RC(Release Candidate) 단계에요.
주요 기능을 뽑아보면 이렇습니다.
- Full-document SSR — 서버에서 전체 HTML을 렌더링해서 성능과 SEO를 개선
- 스트리밍 — 준비된 부분부터 점진적으로 전달
- 서버 함수 — 클라이언트에서 서버 로직을 직접 호출하되 타입이 끝까지 유지됨
- 미들웨어 — 인증, 로깅, 권한 확인 같은 공통 로직을 체이닝으로 구성
- API 라우트 — REST 엔드포인트를 별도로 구축 가능
- 범용 배포 — Cloudflare, Netlify, AWS 등 어디든 배포 가능
참고로 아직 React Server Components(RSC)는 지원하지 않습니다. TanStack 팀에서 통합 작업 중이라고 하니 조만간 추가될 것으로 보입니다.
프로젝트 생성하기
TanStack Start 프로젝트를 처음부터 만들어 보겠습니다.
먼저 디렉토리를 만들고 패키지를 초기화합니다.
mkdir my-app
cd my-app
npm init -y
핵심 패키지들을 설치합니다.
bun add @tanstack/react-start @tanstack/react-router react react-dom
bun add -d vite @vitejs/plugin-react typescript @types/react @types/react-dom @types/node
package.json에 모듈 타입과 스크립트를 추가합니다.
{
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build"
}
}
TypeScript 설정 파일도 만들어줍니다.
{
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ES2022",
"skipLibCheck": true,
"strictNullChecks": true
}
}
Vite 설정
TanStack Start는 Vite 플러그인 형태로 통합됩니다.
vite.config.ts를 만들어서 두 개의 플러그인을 등록합니다.
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
server: {
port: 3000,
},
plugins: [tanstackStart(), react()],
});
tanstackStart() 플러그인이 파일 기반 라우팅, 서버 함수 변환, SSR 설정 같은 무거운 작업을 다 처리해 줍니다.
Vite 설정을 해본 적 있으면 익숙한 구조일 겁니다.
라우터 설정
TanStack Start의 라우팅은 TanStack Router가 담당합니다.
src/router.tsx 파일에서 라우터를 생성합니다.
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
routeTree.gen은 TanStack이 src/routes/ 디렉토리 구조를 분석해서 자동 생성하는 파일입니다.
개발 서버를 띄우면 라우트 트리가 알아서 만들어집니다.
마지막 declare module 부분이 포인트인데요.
이걸 선언해 두면 앱 어디서든 라우터 타입 정보를 쓸 수 있습니다.
링크 경로를 잘못 쓰면 TypeScript가 바로 빨간 줄로 알려주죠.
루트 라우트 만들기
모든 페이지의 기본 틀이 되는 루트 라우트를 만듭니다.
HTML의 <html>, <head>, <body> 태그를 여기서 정의합니다.
import type { ReactNode } from "react";
import {
Outlet,
createRootRoute,
HeadContent,
Scripts,
} from "@tanstack/react-start";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "TanStack Start App" },
],
}),
component: RootComponent,
});
function RootComponent() {
return (
<html lang="ko">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
Next.js의 layout.tsx와 비슷한 역할이지만 좀 다릅니다.
head() 메서드에서 메타 태그를 객체로 선언하면 <HeadContent />가 렌더링해 주고요.
<Scripts />가 클라이언트 JavaScript를 주입해서 하이드레이션을 처리합니다.
<Outlet />은 하위 라우트의 컴포넌트가 들어가는 자리고요.
첫 번째 페이지 만들기
이제 인덱스 페이지를 만들어 봅시다.
src/routes/index.tsx 파일을 생성하면 / 경로에 매핑됩니다.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
return (
<main>
<h1>안녕하세요!</h1>
<p>TanStack Start로 만든 첫 번째 페이지입니다.</p>
</main>
);
}
createFileRoute("/")가 이 파일을 / 경로로 등록합니다.
파일 이름이 index.tsx이니까 인자로 "/"를 넘기는 거고, 만약 about.tsx라면 "/about"이 됩니다.
개발 서버를 시작해서 확인해 봅시다.
bun run dev
브라우저에서 http://localhost:3000에 접속하면 방금 만든 페이지가 보입니다.
서버 함수
TanStack Start에서 가장 눈에 띄는 기능은 서버 함수입니다. 서버에서만 실행되는 함수를 정의하고, 클라이언트 코드에서 마치 일반 함수처럼 호출할 수 있습니다.
import { createServerFn } from "@tanstack/react-start";
const getServerTime = createServerFn().handler(async () => {
// 이 코드는 서버에서만 실행됩니다
return new Date().toISOString();
});
// 클라이언트에서 이렇게 호출하면
const time = await getServerTime();
// 내부적으로 HTTP 요청이 만들어져서 서버로 보내집니다
겉보기엔 그냥 함수 호출입니다. 하지만 빌드할 때 서버 코드가 클라이언트 번들에서 빠지고 그 자리에 HTTP 요청 코드가 들어갑니다. 그래서 데이터베이스 접근이나 환경 변수 읽기, 파일 시스템 접근 같은 서버 전용 작업을 안전하게 쓸 수 있어요.
서버 함수로 데이터 로딩하기
서버 함수가 진짜 빛을 발하는 건 라우트의 loader와 같이 쓸 때입니다.
간단한 카운터 예제로 살펴볼게요.
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
let count = 0;
const getCount = createServerFn({ method: "GET" }).handler(async () => {
return count;
});
const incrementCount = createServerFn({ method: "POST" }).handler(async () => {
count++;
});
export const Route = createFileRoute("/")({
component: Counter,
loader: async () => await getCount(),
});
function Counter() {
const router = useRouter();
const count = Route.useLoaderData();
return (
<div>
<p>현재 카운트: {count}</p>
<button
onClick={() => {
incrementCount().then(() => router.invalidate());
}}
>
+1
</button>
</div>
);
}
loader에서 getCount()를 호출하면 SSR 시점에 서버에서 데이터를 가져와 HTML에 담아 보냅니다.
버튼을 클릭하면 incrementCount()가 서버로 POST 요청을 날리고 router.invalidate()로 화면을 갱신하고요.
주목할 부분은 Route.useLoaderData()입니다.
loader의 반환 타입이 여기까지 자동으로 흘러와서 별도 타입 선언 없이도 count가 number로 추론돼요.
입력 검증
서버 함수에 파라미터를 넘길 때는 inputValidator로 유효성 검증을 걸 수 있습니다.
const greetUser = createServerFn({ method: "GET" })
.inputValidator((data: { name: string }) => data)
.handler(async ({ data }) => {
return `안녕하세요, ${data.name}님!`;
});
// 호출할 때 data 속성으로 인자를 전달
const greeting = await greetUser({ data: { name: "달레" } });
간단한 타입 검증 외에 Zod 같은 스키마 라이브러리를 연결할 수도 있습니다.
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1),
age: z.number().min(0),
});
const createUser = createServerFn({ method: "POST" })
.inputValidator(UserSchema)
.handler(async ({ data }) => {
// data는 { name: string; age: number } 타입으로 추론
return `${data.name}님(${data.age}세)을 등록했습니다.`;
});
Zod 스키마를 넘기면 런타임 유효성 검사와 TypeScript 타입 추론을 한 번에 해결할 수 있습니다. 검증 로직과 타입 정의가 한 곳에 있으니 관리하기도 편하고요.
에러 처리와 리디렉트
서버 함수에서 에러를 던지면 클라이언트에서 try/catch로 잡을 수 있습니다.
인증이 필요한 경우에는 redirect를 활용합니다.
import { redirect } from "@tanstack/react-router";
const requireAuth = createServerFn().handler(async () => {
const user = await getCurrentUser();
if (!user) {
throw redirect({ to: "/login" });
}
return user;
});
데이터가 없을 때는 notFound를 던질 수 있습니다.
import { notFound } from "@tanstack/react-router";
const getPost = createServerFn()
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const post = await db.findPost(data.id);
if (!post) {
throw notFound();
}
return post;
});
redirect와 notFound 모두 TanStack Router에서 가져오는 건데 서버 함수 안에서도 똑같이 동작합니다.
라우터와 프레임워크가 한 몸처럼 붙어 있다는 걸 잘 보여주죠.
미들웨어
인증, 로깅, 권한 확인 같은 공통 로직을 매번 서버 함수마다 반복하고 싶지 않다면 미들웨어를 활용할 수 있습니다.
import { createMiddleware } from "@tanstack/react-start";
const authMiddleware = createMiddleware().server(async ({ next, request }) => {
const session = await getSession(request.headers);
if (!session) {
throw new Error("인증이 필요합니다");
}
return next({
context: { session },
});
});
next()를 호출하면서 context에 데이터를 넘기면 다음 미들웨어나 서버 함수에서 그 데이터를 받아 쓸 수 있습니다.
미들웨어끼리 체이닝도 가능합니다.
const adminMiddleware = createMiddleware()
.middleware([authMiddleware])
.server(async ({ next, context }) => {
// authMiddleware에서 넘겨준 session을 사용
if (context.session.role !== "admin") {
throw new Error("관리자 권한이 필요합니다");
}
return next();
});
authMiddleware가 먼저 실행되고 통과하면 adminMiddleware가 실행되는 구조입니다.
이렇게 만들어둔 미들웨어를 서버 함수에 적용하면 됩니다.
const deleteUser = createServerFn()
.middleware([adminMiddleware])
.handler(async ({ context }) => {
// context.session이 타입 안전하게 사용 가능
return { success: true };
});
미들웨어에서 context에 넣은 session의 타입이 handler까지 자동으로 전파됩니다.
미들웨어 체인 전체에서 타입이 흐르기 때문에 context.session을 쓸 때 자동 완성이 바로 뜨거든요.
이게 TanStack Start가 말하는 “엔드투엔드 타입 안전성”의 실체입니다.
서버 컨텍스트 활용
서버 함수 안에서 HTTP 요청/응답을 직접 다뤄야 할 때도 있습니다. TanStack Start는 이를 위한 유틸리티 함수를 제공합니다.
import {
getRequest,
getRequestHeader,
setResponseHeaders,
setResponseStatus,
} from "@tanstack/react-start/server";
const getCachedData = createServerFn({ method: "GET" }).handler(async () => {
const authHeader = getRequestHeader("Authorization");
setResponseHeaders(
new Headers({
"Cache-Control": "public, max-age=300",
}),
);
setResponseStatus(200);
return fetchData();
});
getRequest()로 전체 Request 객체에 접근하거나 getRequestHeader()로 특정 헤더를 읽거나 setResponseHeaders()로 응답 헤더를 설정할 수 있습니다.
파일 구조
지금까지 만든 파일들을 정리하면 이런 구조가 됩니다.
my-app/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── routeTree.gen.ts ← 자동 생성
└── routes/
├── __root.tsx
└── index.tsx
src/routes/ 디렉토리에 파일을 추가하면 자동으로 라우트가 등록됩니다.
about.tsx를 만들면 /about 경로가 생기고, posts/$postId.tsx를 만들면 /posts/:postId 동적 라우트가 됩니다.
규모가 커지면 서버 함수를 별도 파일로 분리하는 것도 좋습니다.
src/
├── routes/
│ ├── __root.tsx
│ ├── index.tsx
│ └── posts/
│ ├── index.tsx
│ └── $postId.tsx
└── utils/
├── posts.functions.ts ← 서버 함수
├── posts.server.ts ← 서버 전용 헬퍼
└── schemas.ts ← 공유 검증 스키마
Next.js와 뭐가 다른가요?
같은 풀스택 React 프레임워크지만 철학이 꽤 다릅니다.
Next.js는 서버 컴포넌트가 기본이고 상호작용이 필요한 곳에서만 "use client"를 선언하죠.
TanStack Start는 정반대입니다. 모든 컴포넌트가 기본적으로 클라이언트에서 돌아가고, 서버 로직은 서버 함수로 명시적으로 떼어냅니다.
타입 안전성 측면도 눈에 띕니다.
TanStack Start는 라우트 경로, 검색 파라미터, 서버 함수 입출력, 미들웨어 컨텍스트까지 전부 컴파일 타임에 타입 체크가 돼요.
<Link to="/posst">처럼 경로를 잘못 치면 에디터에서 바로 빨간 줄이 뜹니다.
Next.js도 타입은 지원하지만 라우트 경로의 타입 안전성은 IDE 플러그인 수준에 머물러 있고요.
캐싱 방식도 다릅니다. Next.js 캐싱은 요청 메모이제이션, 데이터 캐시, 전체 라우트 캐시, 라우터 캐시 등 여러 계층이 겹쳐서 동작을 예측하기 까다로울 때가 있는데요. TanStack Start는 TanStack Query의 SWR(stale-while-revalidate) 패턴을 그대로 가져갑니다. 이미 TanStack Query를 써봤다면 새로 배울 게 없다는 뜻이죠.
배포도 마찬가지입니다. Next.js가 Vercel에 최적화되어 있는 반면, TanStack Start는 Vite 기반이라 Cloudflare든 Netlify든 AWS든 가리지 않습니다.
물론 Next.js도 분명한 장점이 있습니다. 생태계가 훨씬 크고 튜토리얼이나 커뮤니티 자료도 압도적으로 많아요. RSC도 이미 안정화됐고요. TanStack Start는 아직 RC 단계라 프로덕션 투입은 좀 이를 수 있습니다.
마치며
TanStack Start는 타입 안전성과 개발자 자유도에 집중한 풀스택 React 프레임워크입니다. TanStack Router의 타입 안전한 라우팅 위에 서버 함수, 미들웨어, SSR을 얹었고 Vite 기반이라 빌드도 빠르고 배포도 자유롭습니다.
createServerFn으로 서버 로직을 만들고 클라이언트에서 그냥 함수 호출하듯 쓰는 패턴이 정말 깔끔한데요.
미들웨어까지 연결하면 인증이나 권한 체크 같은 로직도 체계적으로 관리할 수 있습니다.
아직 RC고 RSC 미지원 같은 제약이 있으니 당장 프로덕션보다는 사이드 프로젝트에서 먼저 맛보시길 추천합니다. TanStack Query나 TanStack Router를 이미 쓰고 있으면 적응이 훨씬 수월할 거예요. AI 기능이 필요하다면 같은 생태계의 TanStack AI도 살펴보시면 좋겠습니다.
더 자세한 내용은 TanStack Start 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0