---
id: frontend-progressive-enhancement
title: Progressive Enhancement — Server-first / 점진 JS
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [frontend, progressive-enhancement, html, vibe-coding]
tech_stack: { language: "HTML / TS", applicable_to: ["Frontend"] }
applied_in: []
aliases: [progressive enhancement, server-first, no-JS fallback, HTMX, web standards, MPA]
---

# Progressive Enhancement

> Server-rendered HTML 부터 → JS 로 향상. **JS 없어도 작동, 있으면 더 좋음**. Remix / Next App Router / HTMX / web standards. SPA 의 reaction.

## 📖 핵심 개념
- HTML 가 baseline.
- Form / link 가 native (JS 없어도).
- JS = enhancement.
- 학교 wifi / slow 3G / 옛 browser 도 작동.

## 💻 코드 패턴

### 기본 — Form 가 native
```html
<form method="post" action="/api/login">
  <input name="email" type="email" required>
  <input name="password" type="password" required minlength="8" required>
  <button>Sign in</button>
</form>
```

→ JS 없이 작동. 검증도 native.

### Next.js Server Action (PE)
```tsx
// app/login/page.tsx
async function login(formData: FormData) {
  'use server';
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  // ... auth
  redirect('/dashboard');
}

export default function LoginPage() {
  return (
    <form action={login}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button>Sign in</button>
    </form>
  );
}
```

→ JS 없어도 form submit. JS 가 활성화되면 SPA-like UX.

### useFormStatus (loading state)
```tsx
'use client';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Signing in…' : 'Sign in'}</button>;
}
```

→ JS 활성 시 loading 표시. JS 없으면 기본 button.

### Remix loader / action
```tsx
// app/routes/login.tsx
export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const email = formData.get('email');
  // ...
  return redirect('/dashboard');
}

export default function Login() {
  return (
    <Form method="post">
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button>Sign in</button>
    </Form>
  );
}
```

→ Remix `<Form>` = JS 없어도 native form, JS 있으면 SPA submit.

### HTMX (server-rendered + AJAX)
```html
<button hx-post="/api/like" hx-target="#likes">
  Like
</button>
<span id="likes">42</span>
```

→ Server 가 새 HTML fragment 반환. JS 없으면 일반 button (또는 fallback).

```html
<!-- Fallback -->
<form action="/like" method="post">
  <button hx-post="/api/like" hx-target="#likes" hx-swap="innerHTML">
    Like
  </button>
</form>
<span id="likes">42</span>
```

### Native dialog (JS 안)
```html
<dialog id="modal">
  <form method="dialog">
    <p>Are you sure?</p>
    <button value="confirm">Yes</button>
    <button value="cancel">No</button>
  </form>
</dialog>

<button onclick="document.getElementById('modal').showModal()">Open</button>
```

→ JS 안 쓰고도 modal. (showModal 만 JS — fallback 가능).

### View Transitions (declarative animation)
```html
<a href="/about">About</a>
<!-- 새 page navigation 자동 transition (Chrome) -->
```

```css
@view-transition { navigation: auto; }
```

### Anchor link / form / radio = SPA-like 가능
```html
<!-- Tabs = radio + label -->
<input type="radio" id="tab1" name="tabs" checked>
<label for="tab1">Tab 1</label>
<input type="radio" id="tab2" name="tabs">
<label for="tab2">Tab 2</label>

<div class="content">
  <section data-tab="tab1">Tab 1 content</section>
  <section data-tab="tab2">Tab 2 content</section>
</div>

<style>
  section[data-tab] { display: none; }
  #tab1:checked ~ .content [data-tab="tab1"] { display: block; }
  #tab2:checked ~ .content [data-tab="tab2"] { display: block; }
</style>
```

→ JS 0 — 작동.

### Search — native form + URL
```tsx
<form method="get" action="/search">
  <input name="q" type="search" placeholder="Search…">
  <button>Search</button>
</form>
```

→ JS 가 query param + result render. 없어도 server 가 SSR.

### Optimistic UI + rollback
```tsx
'use client';
import { useOptimistic } from 'react';

const [optimistic, addOptimistic] = useOptimistic(items);

async function add(item) {
  addOptimistic([...items, item]);  // 즉시 UI
  await fetch('/api/add', { method: 'POST', body: JSON.stringify(item) });
}
```

→ JS 활성 시 즉시 반응. 없으면 server 의 form submit.

### loading="lazy", decoding="async"
```html
<img src="..." loading="lazy" decoding="async" alt="...">
<iframe src="..." loading="lazy"></iframe>
```

### Tailwind + native HTML
```html
<details class="border rounded p-4">
  <summary class="cursor-pointer font-bold">FAQ</summary>
  <p class="mt-2">Answer</p>
</details>
```

→ JS 없는 accordion.

### Form validation (browser native)
```html
<input type="email" required>
<input type="number" min="0" max="100" step="0.1">
<input pattern="[A-Z]{3}-\d{4}" title="ABC-1234">
<input type="text" required minlength="3" maxlength="20">
```

```css
input:invalid { border-color: red; }
input:invalid:not(:placeholder-shown) { ... }
```

### Web standards 활용
```html
<input type="date">     <!-- date picker native -->
<input type="color">    <!-- color picker -->
<input type="range">    <!-- slider -->
<input type="search">   <!-- clear button native -->
<input type="file" accept="image/*" multiple>
```

→ JS 라이브러리 없이.

### Fetch with form + JSON 전환
```tsx
async function handleSubmit(e: FormEvent) {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(e.target as HTMLFormElement));
  await fetch('/api/...', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(data),
  });
}

<form onSubmit={handleSubmit} action="/api/..." method="post">
  ...
</form>
```

→ JS 활성 = JSON. 없으면 form encode + server 가 처리.

### MPA vs SPA vs PE
```
MPA (Multi-Page App):  서버 매번 HTML 보냄. 옛.
SPA:                  JS 가 모든 거. PE 무시 자주.
Progressive Enhanced: 둘 다 — MPA 처럼 baseline, SPA 처럼 향상.

→ Next App Router / Remix / Phoenix LiveView / Rails Hotwire.
```

### a11y 자연
```html
<button>Click</button>          <!-- focus, role 자동 -->
<a href="...">Link</a>          <!-- 키보드 navigation 자동 -->
<form>                          <!-- enter submit, label association -->
```

→ Custom div 보다 우월.

### React Server Components (RSC)
```tsx
// 서버 컴포넌트 — JS 0 client
async function Page() {
  const data = await db.query(...);
  return <ul>{data.map(d => <li>{d}</li>)}</ul>;
}
```

→ HTML 만 보냄. 작은 page = JS 0.

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Public site (SEO, accessibility) | PE strict |
| 내부 dashboard (관리자만) | SPA OK |
| Form-heavy | Server actions / Remix |
| 인터랙션 없는 콘텐츠 | RSC + 0 client JS |
| 복잡 인터랙션 | SPA + PE 가능 한도 |
| 옛 browser / slow 3G | PE 강 |

## ❌ 안티패턴
- **모든 거 client-side JS**: SEO / a11y / slow.
- **`<div onClick>` button**: 키보드 안 됨.
- **Form 가 fetch + JSON 만 — server action 없음**: JS X = 작동 X.
- **Required JS — fallback X**: 옛 browser fail.
- **Client-only routing — server URL 안 됨**: refresh = 404.
- **Loading skeleton 만 + 데이터 없음**: PE 무시.

## 🤖 LLM 활용 힌트
- Form action + method = baseline.
- Native HTML 우선 (button, dialog, details).
- JS 는 향상 — 없어도 작동.
- Next App Router / Remix 가 PE 친화.

## 🔗 관련 문서
- [[React_RSC_Server_Actions_Deep]]
- [[Frontend_A11y_Testing]]
- [[Web_Performance_Core_Vitals]]
