TanStack Start로 풀 스택 DevJokes 앱 구축하기
이 튜토리얼에서는 TanStack Start를 사용해 완전한 풀 스택 애플리케이션을 구축하는 방법을 안내합니다. 사용자가 개발자 테마의 농담을 보고 추가할 수 있는 DevJokes 앱을 만들면서 서버 함수, 파일 기반 데이터 저장소 및 React 컴포넌트를 비롯한 TanStack Start의 핵심 개념을 살펴봅니다.
다음은 앱이 실제로 작동하는 데모입니다:
이 튜토리얼의 전체 코드는 GitHub에서 확인할 수 있습니다.
학습할 내용
- TanStack Start 프로젝트 설정하기
- 서버 함수 구현하기
- 파일에서 데이터 읽고 쓰기
- React 컴포넌트로 완전한 UI 구축하기
- 데이터 가져오기와 탐색에 TanStack Router 사용하기
사전 요구 사항
- React와 TypeScript에 대한 기본 지식이 필요합니다.
- 컴퓨터에 Node.js와
pnpm가 설치되어 있어야 합니다
알아두면 좋은 내용
TanStack Start 프로젝트 설정하기
먼저 새로운 TanStack Start 프로젝트를 생성합니다:
pnpx @tanstack/cli@latest create devjokes
cd devjokes
이 스크립트를 실행하면 몇 가지 설정 관련 질문이 표시됩니다. 필요에 맞는 항목을 선택하거나 Enter 키를 눌러 기본값을 적용할 수 있습니다.
선택적으로 --add-on 플래그를 전달하여 Shadcn, Clerk, Convex, TanStack Query 등의 옵션을 사용할 수 있습니다.
설정이 완료되면 종속성을 설치하고 개발 서버를 시작합니다:
pnpm i
pnpm dev
이 프로젝트에는 uuid 패키지가 필요합니다:
# Install uuid for generating unique IDs
pnpm add uuid
프로젝트 구조 이해하기
이 시점의 프로젝트 구조는 다음과 같습니다 -
/devjokes
├── src/
│ ├── routes/
│ │ ├── demo/ # Demo routes
│ │ ├── __root.tsx # Root layout
│ │ └── index.tsx # Home page
│ ├── components/ # React components
│ ├── data/ # Data files
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ └── styles.css # Global styles
├── public/ # Static assets
├── vite.config.ts or rsbuild.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
이 구조가 처음에는 복잡해 보일 수 있지만, 중점적으로 살펴봐야 할 핵심 파일은 다음과 같습니다:
src/router.tsx- 애플리케이션의 라우팅을 설정합니다src/routes/__root.tsx- 전역 스타일과 컴포넌트를 추가할 수 있는 루트 레이아웃 컴포넌트입니다src/routes/index.tsx- 홈페이지입니다
프로젝트 설정이 완료되면 localhost:3000에서 앱에 접근할 수 있습니다. 기본 TanStack Start 시작 페이지가 표시됩니다.
이 시점의 앱은 다음과 같습니다:

1단계: 파일에서 데이터 읽기
먼저 농담을 위한 파일 기반 저장소 시스템을 만듭니다.
1.1단계: 농담이 포함된 JSON 파일 만들기
페이지에 렌더링할 농담 목록을 설정합니다. src/data 내부에 jokes.json 파일을 만듭니다:
touch src/data/jokes.json
이제 이 파일에 몇 가지 예시 농담을 추가합니다:
[
{
"id": "1",
"question": "Why don't keyboards sleep?",
"answer": "Because they have two shifts"
},
{
"id": "2",
"question": "Are you a RESTful API?",
"answer": "Because you GET my attention, PUT some love, POST the cutest smile, and DELETE my bad day"
},
{
"id": "3",
"question": "I used to know a joke about Java",
"answer": "But I ran out of memory."
},
{
"id": "4",
"question": "Why do Front-End Developers eat lunch alone?",
"answer": "Because, they don't know how to join tables."
},
{
"id": "5",
"question": "I am declaring a war.",
"answer": "var war;"
}
]
1.2단계: 데이터 타입 만들기
데이터 타입을 정의할 파일을 만듭니다. src/types/index.ts에 새 파일을 만듭니다:
// src/types/index.ts
export interface Joke {
id: string
question: string
answer: string
}
export type JokesData = Joke[]
1.3단계: 파일을 읽는 서버 함수 만들기
읽기-쓰기 작업을 수행하는 서버 함수를 만들기 위해 새 파일 src/serverActions/jokesActions.ts을 만듭니다. createServerFn을 사용하여 서버 함수를 만듭니다.
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import type { JokesData } from '../types'
const JOKES_FILE = 'src/data/jokes.json'
export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})
이 코드에서는 createServerFn을 사용하여 JSON 파일에서 농담을 읽는 서버 함수를 만듭니다. handler 함수에서는 fs 모듈을 사용하여 파일을 읽습니다.
1.4단계: 클라이언트 측에서 서버 함수 사용하기
이제 TanStack Start에 이미 포함된 TanStack Router를 사용하여 코드에서 이 서버 함수를 간단히 호출할 수 있습니다!
이제 약간의 Tailwind 스타일링을 더해 페이지에 농담을 렌더링하는 새 컴포넌트 JokesList을 생성하겠습니다.
// src/components/JokesList.tsx
import { Joke } from '../types'
interface JokesListProps {
jokes: Joke[]
}
export function JokesList({ jokes }: JokesListProps) {
if (!jokes || jokes.length === 0) {
return <p className="text-gray-500 italic">No jokes found. Add some!</p>
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Jokes Collection</h2>
{jokes.map((joke) => (
<div
key={joke.id}
className="bg-white p-4 rounded-lg shadow-md border border-gray-200"
>
<p className="font-bold text-lg mb-2">{joke.question}</p>
<p className="text-gray-700">{joke.answer}</p>
</div>
))}
</div>
)
}
이제 TanStack Start에 이미 포함된 TanStack Router를 사용하여 index.tsx 내부에서 서버 함수를 호출하겠습니다!
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'
export const Route = createFileRoute('/')({
loader: async () => {
// Load jokes data when the route is accessed
return getJokes()
},
component: App,
})
const App = () => {
const jokes = Route.useLoaderData() || []
return (
<div className="max-w-2xl mx-auto py-12 px-4 space-y-6">
<h1 className="text-4xl font-bold text-center mb-10">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}
페이지가 로드되면 jokes에는 이미 jokes.json 파일의 데이터가 들어 있습니다!
약간의 Tailwind 스타일링을 적용하면 앱은 다음과 같이 표시됩니다:

2단계: 파일에 데이터 쓰기
지금까지 파일에서 성공적으로 데이터를 읽었습니다! 같은 방식을 사용하여 createServerFunction으로 jokes.json 파일에 데이터를 쓸 수 있습니다.
2.1단계: 파일에 쓰는 서버 함수 생성
이제 새 농담을 추가할 수 있도록 jokes.json 파일을 수정할 차례입니다. 이번에는 같은 파일에 쓰기 위해 POST 메서드를 사용하는 또 다른 서버 함수를 생성하겠습니다.
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import { v4 as uuidv4 } from 'uuid' // Add this import
import type { Joke, JokesData } from '../types'
const JOKES_FILE = 'src/data/jokes.json'
export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})
// Add this new server function
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// Validate input data
if (!data.question || !data.question.trim()) {
throw new Error('Joke question is required')
}
if (!data.answer || !data.answer.trim()) {
throw new Error('Joke answer is required')
}
return data
})
.handler(async ({ data }) => {
try {
// Read the existing jokes from the file
const jokesData = await getJokes()
// Create a new joke with a unique ID
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// Add the new joke to the list
const updatedJokes = [...jokesData, newJoke]
// Write the updated jokes back to the file
await fs.promises.writeFile(
JOKES_FILE,
JSON.stringify(updatedJokes, null, 2),
'utf-8',
)
return newJoke
} catch (error) {
console.error('Failed to add joke:', error)
throw new Error('Failed to add joke')
}
})
이 코드에서는 다음을 수행합니다:
- 서버에서 실행되지만 클라이언트에서 호출할 수 있는 서버 함수를 생성하기 위해
createServerFn을 사용합니다. 이 서버 함수는 파일에 데이터를 쓰는 데 사용됩니다. - 먼저
validator을 사용하여 입력 데이터의 유효성을 검사합니다. 수신한 데이터가 올바른 형식인지 확인하는 좋은 방법입니다. - 실제 쓰기 작업은
handler함수에서 수행합니다. getJokes은 JSON 파일에서 농담을 읽습니다.addJoke은 입력 데이터의 유효성을 검사하고 파일에 새 농담을 추가합니다.- 농담의 고유 ID를 생성하기 위해
uuidv4()을 사용합니다.
2.2단계: JSON 파일에 농담을 추가하는 폼 추가
이제 홈 페이지를 수정하여 농담을 표시하고 새 농담을 추가하는 폼을 제공하겠습니다. JokeForm.jsx이라는 새 컴포넌트를 생성하고 다음 폼을 추가하겠습니다:
// src/components/JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={handleSubmit} className="mb-8">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="Enter joke answer"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</div>
</form>
)
}
2.3단계: 폼을 서버 함수에 연결
이제 handleSubmit 함수에서 폼을 addJoke 서버 함수에 연결해 보겠습니다. 서버 액션 호출은 간단합니다! 그저 함수를 호출하면 됩니다.
//JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!question || !answer || isSubmitting) return
try {
setIsSubmitting(true)
await addJoke({
data: { question, answer },
})
// Clear form
setQuestion('')
setAnswer('')
// Refresh data
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('Failed to add joke')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="mb-8">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="Enter joke question"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="Enter joke answer"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? 'Adding...' : 'Add Joke'}
</button>
</div>
</form>
)
}
이제 UI는 다음과 같이 표시됩니다:

모든 요소가 함께 작동하는 방식 이해하기
애플리케이션의 여러 부분이 함께 작동하는 방식을 살펴보겠습니다:
-
서버 함수: 서버에서 실행되며 데이터 작업을 처리합니다
getJokes: JSON 파일에서 농담을 읽습니다addJoke: JSON 파일에 새 농담을 추가합니다
-
TanStack Router: 라우팅과 데이터 로딩을 처리합니다
- 로더 함수는 라우트에 접근할 때 농담 데이터를 가져옵니다
useLoaderData는 컴포넌트에서 이 데이터를 사용할 수 있게 합니다router.invalidate()는 새 농담을 추가할 때 데이터를 새로고침합니다
-
React 컴포넌트: 애플리케이션의 UI를 구성합니다
JokesList: 농담 목록을 표시합니다JokeForm: 새 농담을 추가하는 폼을 제공합니다
-
파일 기반 저장소: 농담을 JSON 파일에 저장합니다
- 읽기와 쓰기는 Node.js
fs모듈에서 처리합니다 - 서버를 재시작해도 데이터가 유지됩니다
- 읽기와 쓰기는 Node.js
애플리케이션을 통한 데이터 흐름
데이터 흐름
sequenceDiagram
autonumber
actor User
participant UI as Browser (HomePage + Form)
participant Loader as Route Loader (loader)
participant Server
participant Store as jokes.json
%% Visiting the Home Page
User ->> UI: Visit /
UI ->> Loader: loader() calls getJokes()
Loader ->> Server: getJokes()
Server ->> Store: Read jokes.json
Store -->> Server: jokes data
Server -->> Loader: jokes[]
Loader -->> UI: useLoaderData() → jokes[]
%% Adding a New Joke
User ->> UI: Fill form and submit
UI ->> Server: handleSubmit → addJoke(newJoke)
Server ->> Store: Read jokes.json
Server ->> Store: Write updated jokes.json
Server -->> UI: addJoke() resolved
UI ->> Loader: router.invalidate() (re-run loader)
Loader ->> Server: getJokes()
Server ->> Store: Read jokes.json
Store -->> Server: updated jokes[]
Server -->> Loader: updated jokes[]
Loader -->> UI: useLoaderData() → updated jokes[]
사용자가 홈페이지를 방문하면 다음과 같이 진행됩니다:
- 라우트의
loader함수가getJokes()서버 함수를 호출합니다 - 서버가
jokes.json를 읽고 농담 데이터를 반환합니다 - 이 데이터는
useLoaderData()를 통해HomePage컴포넌트에 전달됩니다 HomePage컴포넌트가 데이터를JokesList컴포넌트에 전달합니다
사용자가 새 농담을 추가하면 다음과 같이 진행됩니다:
- 폼을 작성하고 제출합니다
handleSubmit함수가addJoke()서버 함수를 호출합니다- 서버가 현재 농담을 읽고 새 농담을 추가한 다음, 업데이트된 데이터를
jokes.json에 다시 씁니다 - 작업이 완료되면
router.invalidate()를 호출하여 데이터를 새로고침합니다 - 그러면 로더가 다시 실행되어 업데이트된 농담을 가져옵니다
- UI가 업데이트되어 목록에 새 농담이 표시됩니다
다음은 실제로 작동하는 앱의 데모입니다:
일반적인 문제 및 디버깅
TanStack Start 애플리케이션을 빌드할 때 흔히 발생할 수 있는 몇 가지 문제와 해결 방법은 다음과 같습니다:
서버 함수가 작동하지 않음
서버 함수가 예상대로 작동하지 않는 경우:
- 올바른 HTTP 메서드(
GET,POST등)를 사용하고 있는지 확인합니다 - 파일 경로가 올바르고 서버에서 접근할 수 있는지 확인합니다
- 서버 콘솔에서 오류 메시지를 확인합니다
- 서버 함수에서 클라이언트 전용 API를 사용하고 있지 않은지 확인합니다
라우트 데이터가 로드되지 않음
라우트 데이터가 제대로 로드되지 않는 경우:
- 로더 함수가 올바르게 구현되었는지 확인합니다
useLoaderData()을 올바르게 사용하고 있는지 확인합니다- 브라우저 콘솔에서 오류를 확인합니다
- 서버 함수가 올바르게 작동하는지 확인합니다
폼 제출 문제
폼 제출이 작동하지 않는 경우:
- 서버 함수에 유효성 검사 오류가 있는지 확인합니다
- 폼 이벤트 방지(
e.preventDefault())가 작동하는지 확인합니다 - 상태 업데이트가 올바르게 이루어지는지 확인합니다
- 브라우저의 개발자 도구에서 네트워크 오류를 확인합니다
파일 읽기/쓰기 문제
파일 기반 저장소를 사용하는 경우:
- 파일 경로가 올바른지 확인합니다
- 파일 권한을 확인합니다
await을 사용하여 비동기 작업을 올바르게 처리하고 있는지 확인합니다- 파일 작업에 적절한 오류 처리를 추가합니다
마무리
축하합니다! TanStack Start를 사용하여 풀스택 DevJokes 앱을 빌드했습니다. 이 튜토리얼에서 다음 내용을 배웠습니다:
- TanStack Start 프로젝트를 설정하는 방법
- 데이터 작업을 위한 서버 함수를 구현하는 방법
- 파일에서 데이터를 읽고 파일에 데이터를 쓰는 방법
- UI를 위한 React 컴포넌트를 빌드하는 방법
- 라우팅 및 데이터 가져오기에 TanStack Router를 사용하는 방법
이 간단한 애플리케이션은 최소한의 코드로 풀스택 애플리케이션을 빌드할 수 있는 TanStack Start의 강력한 기능을 보여 줍니다. 다음과 같은 기능을 추가하여 이 앱을 확장할 수 있습니다:
- 농담 카테고리
- 농담 편집 및 삭제 기능
- 사용자 인증
- 좋아하는 농담에 투표하는 기능
이 튜토리얼의 전체 코드는 GitHub에서 확인할 수 있습니다.