[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
---
|
||||
id: backend-server-components-pattern
|
||||
title: Server Components / Server Actions / TanStack Start
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [backend, server-components, fullstack, vibe-coding]
|
||||
tech_stack: { language: "TS / React", applicable_to: ["Backend", "Frontend"] }
|
||||
applied_in: []
|
||||
aliases: [RSC, server actions, TanStack Start, fullstack TS, server functions, isomorphic]
|
||||
---
|
||||
|
||||
# Server Components / Server Functions
|
||||
|
||||
> Frontend / backend 경계가 흐려짐. **RSC (React Server Components), Next App Router, TanStack Start, Remix, Astro**. Server function = REST endpoint 의 typed alternative.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- RSC: 서버 render, 0 client JS.
|
||||
- Server function: 'use server' — type-safe RPC.
|
||||
- 'use client': 인터랙션 component.
|
||||
- Streaming: Suspense + 점진 hydration.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Next.js App Router (RSC)
|
||||
```tsx
|
||||
// app/users/page.tsx — server component (default)
|
||||
async function UsersPage() {
|
||||
const users = await db.user.findMany();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<UserList users={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// app/users/UserList.tsx — server
|
||||
function UserList({ users }: { users: User[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{users.map(u => <li key={u.id}>{u.email}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// app/users/UserSearch.tsx — client
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UserSearch() {
|
||||
const [q, setQ] = useState('');
|
||||
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
→ Server component 가 default. 인터랙션만 'use client'.
|
||||
|
||||
### Server Action (mutation)
|
||||
```tsx
|
||||
// app/users/actions.ts
|
||||
'use server';
|
||||
|
||||
import { db } from '@/db';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateUser = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function createUser(formData: FormData) {
|
||||
const data = CreateUser.parse(Object.fromEntries(formData));
|
||||
await db.user.create({ data });
|
||||
revalidatePath('/users');
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Form
|
||||
import { createUser } from './actions';
|
||||
|
||||
<form action={createUser}>
|
||||
<input name="email" type="email" />
|
||||
<input name="name" />
|
||||
<button>Create</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
→ JS 없어도 form submit OK + 활성 시 SPA-like.
|
||||
|
||||
### useActionState + useFormStatus
|
||||
```tsx
|
||||
'use client';
|
||||
import { useActionState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return <button disabled={pending}>{pending ? '...' : 'Save'}</button>;
|
||||
}
|
||||
|
||||
function Form() {
|
||||
const [state, formAction] = useActionState(createUser, { error: null });
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="email" />
|
||||
{state.error && <p>{state.error}</p>}
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### TanStack Start (modern)
|
||||
```ts
|
||||
// routes/users.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createServerFn } from '@tanstack/start';
|
||||
|
||||
const fetchUsers = createServerFn('GET', async () => {
|
||||
return db.user.findMany();
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/users')({
|
||||
loader: () => fetchUsers(),
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
function UsersPage() {
|
||||
const users = Route.useLoaderData();
|
||||
return <ul>{users.map(u => <li key={u.id}>{u.email}</li>)}</ul>;
|
||||
}
|
||||
```
|
||||
|
||||
→ Type-safe server function — RPC.
|
||||
|
||||
### Mutation (TanStack Start)
|
||||
```ts
|
||||
const createUser = createServerFn('POST', async (input: { email: string; name: string }) => {
|
||||
return db.user.create({ data: input });
|
||||
});
|
||||
|
||||
// Component
|
||||
async function handleSubmit(formData: FormData) {
|
||||
await createUser({
|
||||
email: formData.get('email') as string,
|
||||
name: formData.get('name') as string,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Remix
|
||||
```tsx
|
||||
// app/routes/users.tsx
|
||||
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
|
||||
import { useLoaderData, Form } from '@remix-run/react';
|
||||
|
||||
export async function loader() {
|
||||
return json(await db.user.findMany());
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
await db.user.create({
|
||||
data: {
|
||||
email: formData.get('email') as string,
|
||||
name: formData.get('name') as string,
|
||||
},
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const users = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<>
|
||||
<Form method="post">...</Form>
|
||||
<ul>{users.map(u => <li key={u.id}>{u.email}</li>)}</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming (Suspense)
|
||||
```tsx
|
||||
import { Suspense } from 'react';
|
||||
|
||||
async function SlowPanel() {
|
||||
const data = await fetch('https://slow-api.com').then(r => r.json());
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<h1>Title</h1> {/* 즉시 보임 */}
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<SlowPanel /> {/* 도착 시 stream */}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
→ Fast TTFB + 점진 reveal.
|
||||
|
||||
### Cache (Next 15)
|
||||
```ts
|
||||
'use cache';
|
||||
|
||||
async function getUsers() {
|
||||
'use cache';
|
||||
return db.user.findMany();
|
||||
}
|
||||
|
||||
// 또는 fetch 의 cache option
|
||||
fetch(url, { next: { revalidate: 60, tags: ['users'] } });
|
||||
|
||||
// Invalidate
|
||||
revalidateTag('users');
|
||||
revalidatePath('/users');
|
||||
```
|
||||
|
||||
### 'use client' boundary
|
||||
```tsx
|
||||
// Server component
|
||||
import { ClientCounter } from './counter'; // imports client
|
||||
|
||||
async function Page() {
|
||||
const data = await fetchData();
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.title}</h1>
|
||||
<ClientCounter initial={data.count} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Client component
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ClientCounter({ initial }: { initial: number }) {
|
||||
const [count, setCount] = useState(initial);
|
||||
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
→ Server data → client component prop. Serializable 만.
|
||||
|
||||
### Server function pitfalls
|
||||
```
|
||||
1. Public endpoint — Auth 매번 검사 필요.
|
||||
2. Input validate (Zod / Valibot).
|
||||
3. Rate limit.
|
||||
4. Error handling — exception → user-facing message.
|
||||
5. Logging — PII 제외.
|
||||
```
|
||||
|
||||
```ts
|
||||
'use server';
|
||||
|
||||
export async function deletePost(postId: string) {
|
||||
const user = await getUser();
|
||||
if (!user) throw new Error('Unauthorized');
|
||||
|
||||
const post = await db.post.findUnique({ where: { id: postId } });
|
||||
if (!post) throw new Error('Not found');
|
||||
if (post.userId !== user.id && !user.isAdmin) {
|
||||
throw new Error('Forbidden');
|
||||
}
|
||||
|
||||
await db.post.delete({ where: { id: postId } });
|
||||
revalidatePath('/posts');
|
||||
}
|
||||
```
|
||||
|
||||
### vs REST API
|
||||
```
|
||||
REST:
|
||||
+ Standard
|
||||
+ Multi-client (web / mobile / 3rd party)
|
||||
+ Cache 표준 (HTTP)
|
||||
- Type drift (server / client)
|
||||
|
||||
Server functions:
|
||||
+ Type-safe end-to-end
|
||||
+ Less boilerplate
|
||||
+ Co-located with UI
|
||||
- Single-app (web only)
|
||||
- Cache 어려움 (POST)
|
||||
- 다른 client (mobile) X
|
||||
```
|
||||
|
||||
→ Web only / fullstack TS = server functions.
|
||||
Multi-client / public API = REST.
|
||||
|
||||
### tRPC (related)
|
||||
```ts
|
||||
// Server
|
||||
const appRouter = router({
|
||||
users: {
|
||||
list: publicProcedure.query(() => db.user.findMany()),
|
||||
create: publicProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(({ input }) => db.user.create({ data: input })),
|
||||
},
|
||||
});
|
||||
|
||||
// Client
|
||||
const trpc = createTRPCReact<AppRouter>();
|
||||
const users = trpc.users.list.useQuery();
|
||||
trpc.users.create.useMutation();
|
||||
```
|
||||
|
||||
→ Type-safe + framework agnostic.
|
||||
|
||||
### Caching strategy
|
||||
```
|
||||
Static (build-time):
|
||||
Generate at build → CDN.
|
||||
|
||||
ISR (incremental):
|
||||
Revalidate every N seconds.
|
||||
|
||||
SSR (per-request):
|
||||
Always fresh.
|
||||
|
||||
Client-only:
|
||||
No server.
|
||||
|
||||
Server actions:
|
||||
Mutation → revalidate.
|
||||
```
|
||||
|
||||
### Hydration
|
||||
```
|
||||
1. Server render HTML
|
||||
2. Client receives HTML (visible immediately)
|
||||
3. Client downloads JS
|
||||
4. React hydrates (event listeners attach)
|
||||
|
||||
→ JS 가 작아야 빠른 hydration.
|
||||
```
|
||||
|
||||
### Streaming SSR
|
||||
```
|
||||
Old: Server 가 모든 거 render → send.
|
||||
New: HTML 가 stream — first paint 빠름 + Suspense 가 점진.
|
||||
```
|
||||
|
||||
### Server-only (security)
|
||||
```ts
|
||||
import 'server-only';
|
||||
|
||||
export const apiKey = process.env.API_KEY!;
|
||||
|
||||
// Client component 가 import 시도 = build error.
|
||||
```
|
||||
|
||||
→ Secret 누설 방지.
|
||||
|
||||
### Astro (SSG / SSR / RSC-like)
|
||||
```astro
|
||||
---
|
||||
// Server only — build / request time
|
||||
const users = await db.user.findMany();
|
||||
---
|
||||
|
||||
<html>
|
||||
<body>
|
||||
<h1>Users</h1>
|
||||
{users.map(u => <li>{u.email}</li>)}
|
||||
|
||||
<!-- Island (client) -->
|
||||
<SearchBox client:load />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
→ Static-first + 작은 JS.
|
||||
|
||||
### Phoenix LiveView (Elixir)
|
||||
```elixir
|
||||
# Server 가 HTML diff push
|
||||
defmodule MyAppWeb.UserLive do
|
||||
use MyAppWeb, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, users: list_users())}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"q" => q}, socket) do
|
||||
{:noreply, assign(socket, users: search_users(q))}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
→ Server-driven + WebSocket.
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| Next.js + fullstack TS | App Router + Server Actions |
|
||||
| Type-safe + flexibility | TanStack Start |
|
||||
| Old Remix users | Remix |
|
||||
| Mostly static + 작은 island | Astro |
|
||||
| Multi-client | REST + tRPC |
|
||||
| Real-time / chat | LiveView / Hotwire |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **Server action auth 무 검사**: public endpoint.
|
||||
- **Input validate 없음**: 위험.
|
||||
- **모두 'use client'**: bundle 폭발.
|
||||
- **Server-only secret 누설**: import 'server-only'.
|
||||
- **Server / client component 혼동**: build error.
|
||||
- **Cache 안 — 매 request DB**: latency.
|
||||
- **Rate limit 없음**: DoS.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Server component default + 'use client' 작게.
|
||||
- Server action = form action.
|
||||
- Validate (Zod) + auth + rate limit.
|
||||
- Streaming + Suspense = TTFB 빠름.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[React_RSC_Server_Actions_Deep]]
|
||||
- [[React_TanStack_Router_Patterns]]
|
||||
- [[Backend_Hono_Modern]]
|
||||
Reference in New Issue
Block a user