---
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]]
