| name | nextjs-frontend-guidelines |
| description | Next.js 15 + React 19 + TypeScript 프론트엔드 개발 가이드라인. App Router, Server/Client Component 분리, JWT 이메일 인증(AuthProvider), api 클라이언트(401 자동 갱신), Hydration 보호 패턴, Middleware 라우트 보호, shadcn/ui + Tailwind CSS. 컴포넌트/페이지/API 라우트/데이터 페칭/인증 작업 시 사용. |
| triggers | ["Next.js","nextjs","next.js","React","server component","client component","app router","use client","use server","shadcn","tailwind","AuthProvider","useAuth","next middleware"] |
Next.js 15 Frontend Development Guidelines
Purpose
base/nextjs/ 기반 Next.js 15 + React 19 + TypeScript 범용 프론트엔드 표준.
App Router, Server/Client Component 분리, JWT 이메일 인증, api 클라이언트, Middleware 라우트 보호 패턴을 일관되게 적용한다.
When to Use This Skill
- 컴포넌트/페이지 생성 (Server vs Client 결정)
- JWT 인증 흐름 구현 (로그인/회원가입/로그아웃)
- API 호출 추가 (
api.get/post/patch/delete)
- Middleware 라우트 보호 설정
- Hydration 불일치 방지
- shadcn/ui + Tailwind CSS 스타일링
Quick Start
New Component Checklist
New Page Checklist
Project Structure
src/
├── app/
│ ├── layout.tsx # RootLayout — AuthProvider 등록
│ ├── page.tsx # 홈 페이지
│ ├── loading.tsx # 전역 로딩 UI
│ ├── error.tsx # 전역 에러 바운더리 ('use client')
│ ├── login/page.tsx # 로그인/회원가입 페이지
│ └── api/
│ └── auth/
│ └── session/ # 토큰 쿠키 동기화 API route
├── components/
│ ├── layout/ # Navbar.tsx, Footer.tsx
│ └── ui/ # shadcn/ui 컴포넌트
├── lib/
│ ├── api.ts # API 클라이언트 + 토큰 관리
│ └── utils.ts # cn() 유틸리티
├── providers/
│ └── AuthProvider.tsx # JWT 인증 컨텍스트
└── middleware.ts # 라우트 보호
Core Patterns
Pattern 1 — Server vs Client Component
기본값은 Server Component. 'use client'는 최소화한다.
export default async function UsersPage() {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users`,
{ cache: 'no-store' },
);
if (!res.ok) throw new Error('Failed to fetch users');
const users: User[] = await res.json();
return <UserList users={users} />;
}
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
import type { Metadata } from 'next';
export const metadata: Metadata = { title: 'Page Title' };
export default function Page() {
return <main className="container mx-auto px-4 py-8">{/* content */}</main>;
}
Pattern 2 — AuthProvider + useAuth()
AuthProvider는 layout.tsx에 등록. useAuth()로 인증 상태 및 액션 접근.
interface AuthContextType {
user: UserInfo | null;
isLoading: boolean;
isAuthenticated: boolean;
emailLogin: (email: string, password: string) => Promise<void>;
emailSignUp: (email: string, password: string, username: string) => Promise<void>;
login: (loginResponse: LoginResponse) => void;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
'use client';
import { useAuth } from '@/providers/AuthProvider';
export default function LoginPage() {
const { emailLogin, emailSignUp, isLoading } = useAuth();
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await emailLogin(email, password);
router.push('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
}
'use client';
export function ProtectedSection() {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) return <Skeleton />;
if (!isAuthenticated) return <Link href="/login">Login required</Link>;
return <div>Hello, {user?.username}</div>;
}
Pattern 3 — API 클라이언트 (api.get/post/patch/delete)
src/lib/api.ts의 api 객체는 Client Component 전용 (localStorage 의존).
Server Component 데이터 페칭은 native fetch()를 사용한다. 401 시 refresh token 자동 재시도.
export const api = {
get: <T>(endpoint: string) => apiRequest<T>(endpoint),
post: <T>(endpoint: string, body: unknown) =>
apiRequest<T>(endpoint, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(endpoint: string, body: unknown) =>
apiRequest<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(endpoint: string) =>
apiRequest<T>(endpoint, { method: 'DELETE' }),
};
'use client';
import { api, ApiError } from '@/lib/api';
async function handleUpdate(id: string, data: UpdateInput) {
try {
const result = await api.patch<User>(`/api/v1/users/${id}`, data);
return result;
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 401) router.push('/login');
throw new Error(`Update failed: ${err.statusText}`);
}
throw err;
}
}
export async function getProfile(id: string): Promise<UserInfo> {
return api.get<UserInfo>(`/api/v1/users/${id}`);
}
Pattern 4 — Hydration 보호 + Middleware 라우트 보호
SSR/CSR 불일치 방지를 위한 mounted 패턴. 라우트 보호는 middleware.ts에서 중앙 관리.
'use client';
export function Navbar() {
const [mounted, setMounted] = useState(false);
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => { setMounted(true); }, []);
return (
<nav>
{/* mounted 전에는 빈 자리 — SSR/CSR 불일치 방지 */}
{mounted && !isLoading ? (
isAuthenticated ? <LogoutButton /> : <Link href="/login">Login</Link>
) : (
<div className="w-[72px] h-10" /> {/* 레이아웃 시프트 방지 */}
)}
</nav>
);
}
const PROTECTED_PATHS: string[] = [
'/dashboard',
'/profile',
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const accessToken = request.cookies.get('app_access_token')?.value;
const isProtected = PROTECTED_PATHS.some(p => pathname.startsWith(p));
if (isProtected && !accessToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
Pattern 5 — 토큰 관리 (localStorage + Cookie 동기화)
액세스 토큰은 localStorage + HTTP-only 쿠키에 동기화. 쿠키는 Middleware가 사용.
const ACCESS_TOKEN_KEY = 'app_access_token';
const REFRESH_TOKEN_KEY = 'app_refresh_token';
export function setTokens(accessToken: string, refreshToken: string): void {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
syncTokensToCookies(accessToken, refreshToken);
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
fetch('/api/auth/session', { method: 'DELETE' });
}
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'app_access_token') {
if (!e.newValue) setUser(null);
else refreshUser();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [refreshUser]);
Anti-Patterns
❌ Server Component에서 useState/useEffect 사용
export default async function Page() {
const [data, setData] = useState(null);
useEffect(() => { fetch('/api/data'); }, []);
}
export default async function Page() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/data`, {
cache: 'no-store',
});
const data: Data = await res.json();
return <DataView data={data} />;
}
❌ 불필요한 'use client' 남용
'use client';
export default function AboutPage() {
return <div>Static content here</div>;
}
export default function AboutPage() {
return <div>Static content here</div>;
}
❌ Hydration 불일치 무시 (인증 상태 직접 렌더링)
export function Navbar() {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <LogoutBtn /> : <LoginBtn />;
}
export function Navbar() {
const [mounted, setMounted] = useState(false);
const { isAuthenticated } = useAuth();
useEffect(() => { setMounted(true); }, []);
if (!mounted) return <div className="w-[72px] h-10" />;
return isAuthenticated ? <LogoutBtn /> : <LoginBtn />;
}
❌ Server Component에서 api.get() 사용
export default async function UsersPage() {
const users = await api.get<User[]>('/api/v1/users');
}
export default async function UsersPage() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users`, {
cache: 'no-store',
});
const users: User[] = await res.json();
return <UserList users={users} />;
}
❌ useEffect에서 데이터 페칭
'use client';
export default function UsersPage() {
const [users, setUsers] = useState([]);
useEffect(() => { fetch('/api/v1/users').then(...); }, []);
}
export default async function UsersPage() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users`, {
cache: 'no-store',
});
const users: User[] = await res.json();
return <UserList users={users} />;
}
❌ 토큰을 직접 fetch 헤더에 주입
const token = localStorage.getItem('token');
fetch('/api/v1/data', { headers: { Authorization: `Bearer ${token}` } });
const data = await api.get<Data>('/api/v1/data');
References
base/nextjs/src/providers/AuthProvider.tsx — 인증 컨텍스트 전체 구현
base/nextjs/src/lib/api.ts — API 클라이언트 + 토큰 관리
base/nextjs/src/middleware.ts — 라우트 보호 미들웨어
base/nextjs/src/components/layout/Navbar.tsx — Hydration 보호 패턴
base/nextjs/src/app/login/page.tsx — 로그인/회원가입 폼 패턴