--- name: nextjs-frontend-guidelines description: Next.js 15 frontend development guidelines for YGS (영영사) React 19/TypeScript application. Modern patterns including App Router, Server/Client Components, shadcn/ui components, Tailwind CSS 4, multi-method authentication (Firebase/Kakao/JWT), admin dashboard patterns, and Korean localization. Use when creating components, pages, API routes, fetching data, styling, or working with frontend code. --- # Next.js 15 Frontend Development Guidelines for YGS ## Purpose Comprehensive guide for YGS (영영사) frontend development with Next.js 15, React 19, emphasizing App Router patterns, Server/Client component separation, shadcn/ui components, Tailwind CSS 4 styling, multi-method authentication, and Korean localization. ## When to Use This Skill - Creating new components or pages - Building new features with App Router - Fetching data with Server Components or client-side patterns - Styling components with shadcn/ui and Tailwind CSS 4 - Setting up API routes or Server Actions - Authentication flows (Firebase, Kakao, custom JWT) - Admin dashboard development - Performance optimization - Organizing frontend code - TypeScript best practices --- ## Quick Start ### New Component Checklist Creating a component? Follow this checklist: - [ ] Determine if Server or Client Component - [ ] Use `'use client'` directive only when needed - [ ] Props type with TypeScript interface - [ ] Use `@/` import alias for project imports - [ ] Use shadcn/ui components where applicable - [ ] Use `cn()` utility for conditional classes - [ ] Named export for components - [ ] Async Server Components for data fetching when possible - [ ] Client Components for interactivity (useState, useEffect, event handlers) - [ ] Korean text for UI labels ### New Feature Checklist Creating a feature? Set up this structure: - [ ] Create `src/components/{feature-name}/` directory - [ ] Separate Server and Client components - [ ] Create API route if needed: `src/app/api/{feature}/route.ts` - [ ] Set up TypeScript types in `src/types/` - [ ] Create route in `src/app/{feature-name}/page.tsx` - [ ] Use Server Components by default - [ ] Add Client Components only for interactivity - [ ] Use Server Actions for mutations when appropriate - [ ] Add constants/enums to `src/constants/enums.ts` if needed --- ## Project Structure Your YGS project structure (import with `@/` alias): ``` src/ ├── app/ # Next.js App Router │ ├── page.tsx # Home/Landing page │ ├── layout.tsx # Root layout with metadata │ ├── error.tsx # Error boundary │ ├── admin/ # Admin dashboard (protected) │ │ ├── page.tsx # Dashboard stats │ │ ├── layout.tsx # Admin layout with auth check │ │ ├── members/ # Member management │ │ │ ├── page.tsx # Member list │ │ │ └── [id]/page.tsx # Member detail │ │ ├── consultations/ # Consultation management │ │ ├── matching/ # Matching interface │ │ └── couples/ # Couple management │ ├── login/ # Authentication │ │ ├── page.tsx # Login page │ │ └── kakao-callback/ # Kakao OAuth callback │ ├── form/ # User profile form │ ├── match/ # Matching interface │ ├── buy/ # Membership purchase │ └── api/ │ └── auth/session/ # Token sync endpoint ├── components/ # React components (~60 total) │ ├── admin/ # Admin components (27) │ │ ├── DashboardStats.tsx │ │ ├── MemberTable.tsx │ │ ├── MemberFilters.tsx │ │ ├── ConsultationFormModal.tsx │ │ └── modals/ # Edit modals │ ├── auth/ # Auth components (4) │ │ ├── LoginForm.tsx │ │ ├── SignupForm.tsx │ │ └── SocialLoginButton.tsx │ ├── layout/ # Layout components (2) │ │ ├── Navbar.tsx │ │ └── Footer.tsx │ ├── match/ # Match components (3) │ ├── sections/ # Landing page sections (7) │ ├── seo/ # SEO schema components (4) │ └── ui/ # shadcn/ui components (11) │ ├── button.tsx │ ├── card.tsx │ ├── input.tsx │ ├── dialog.tsx │ ├── select.tsx │ └── ... ├── lib/ # Core utilities │ ├── api.ts # Main API client (token management) │ ├── adminApi.ts # Admin-specific API methods │ ├── serverAuth.ts # Server-side auth validation │ ├── firebaseAuth.ts # Firebase SDK integration │ ├── firebase.ts # Firebase config │ ├── kakao.ts # Kakao SDK integration │ ├── s3Upload.ts # S3 upload utilities │ └── utils.ts # cn() helper ├── providers/ # Context providers │ └── AuthProvider.tsx # Auth state context ├── types/ # TypeScript definitions │ ├── index.ts # Common types │ ├── admin.ts # Admin types (362 lines) │ └── match.ts # Match types ├── constants/ # Constants & enums │ └── enums.ts # Enum options with Korean labels └── middleware.ts # Route protection ``` --- ## Import Patterns | Pattern | Usage | Example | |---------|-------|---------| | `@/` | Project imports (primary) | `import { api } from '@/lib/api'` | | Relative | Same directory | `import { Component } from './Component'` | | `type` | Type-only imports | `import type { User } from '@/types'` | --- ## Common Imports Cheatsheet ```typescript // Server Component (no 'use client') import { Suspense } from 'react'; import { redirect } from 'next/navigation'; import { cookies } from 'next/headers'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { getServerSession } from '@/lib/serverAuth'; import type { Metadata } from 'next'; // Client Component 'use client'; import { useState, useEffect, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useAuth } from '@/providers/AuthProvider'; import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; import { Loader2 } from 'lucide-react'; // Admin API import { getMembers, updateMemberBasic, getDashboardStats } from '@/lib/adminApi'; // Types import type { AdminMember, MemberDetail, MemberFilter } from '@/types/admin'; import type { MatchCard } from '@/types/match'; // Constants import { USER_STATUS_OPTIONS, GENDER_OPTIONS, getEnumLabel } from '@/constants/enums'; ``` --- ## Topic Guides ### shadcn/ui Overview **What is shadcn/ui?** - Beautifully designed, accessible components built on Radix UI - Copy/paste components into your project (not a npm package dependency) - Fully customizable with Tailwind CSS - TypeScript-first with full type safety **Key Concepts:** - Components live in `src/components/ui/` - Use `cn()` utility for class merging (clsx + tailwind-merge) - Variants via class-variance-authority (cva) - Follows Radix UI accessibility patterns **Available Components in YGS:** - button, input, textarea, card, dialog, select, checkbox, badge, alert, skeleton, image-upload **Adding Components:** ```bash npx shadcn@latest add button npx shadcn@latest add card npx shadcn@latest add dialog npx shadcn@latest add form ``` --- ### Component Patterns **Server vs Client Components:** - **Server Components (default)**: Data fetching, static content, no interactivity - **Client Components ('use client')**: State, effects, event handlers, browser APIs **Key Concepts:** - Server Components are async and fetch data directly - Client Components need 'use client' directive at the top - Minimize Client Components for better performance - Pass data from Server to Client Components via props - Component structure: Props -> Hooks -> Handlers -> Render -> Export **YGS-Specific Patterns:** - Most admin components are Client Components (heavy state management) - Landing page sections are Server Components (static content) - Forms use manual state management (not react-hook-form) **[Complete Guide: resources/component-patterns.md](resources/component-patterns.md)** --- ### Authentication (YGS-Specific) **Multi-Method Authentication:** 1. **Firebase Social Auth**: Google, Apple 2. **Kakao OAuth**: Server-side token validation with Firebase exchange 3. **Custom JWT**: 60-min access token, 30-day refresh token **AuthProvider Pattern:** ```typescript 'use client'; import { useAuth } from '@/providers/AuthProvider'; export function MyComponent() { const { user, isLoading, isAuthenticated, signupRequired, loginWithKakao, loginWithGoogle, logout, refreshUser, } = useAuth(); if (isLoading) return ; if (!isAuthenticated) return ; return
Welcome, {user?.nickname}
; } ``` **Server-Side Auth Check (Admin Layout):** ```typescript // app/admin/layout.tsx import { redirect } from 'next/navigation'; import { getServerSession } from '@/lib/serverAuth'; export default async function AdminLayout({ children }) { const session = await getServerSession(); if (!session.isAuthenticated) { redirect('/login'); } // Check admin claim from JWT const claims = parseJwtClaims(session.accessToken); if (!claims?.is_admin) { redirect('/'); } return (
{children}
); } ``` **Hydration Protection Pattern:** ```typescript 'use client'; import { useState, useEffect } from 'react'; import { useAuth } from '@/providers/AuthProvider'; export function Navbar() { const { user, isLoading, isAuthenticated } = useAuth(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); return ( ); } ``` --- ### Data Fetching **PRIMARY PATTERNS:** **Server Component Data Fetching (Recommended):** ```typescript // app/admin/page.tsx import { getDashboardStats, getRegistrationTrend } from '@/lib/adminApi'; export default async function AdminDashboard() { const [stats, trend] = await Promise.all([ getDashboardStats(), getRegistrationTrend(7), ]); return ; } ``` **Client-Side Data Fetching (Admin Pattern):** ```typescript 'use client'; import { useState, useEffect } from 'react'; import { getMembers } from '@/lib/adminApi'; import type { AdminMember, MemberFilter } from '@/types/admin'; export function MemberList() { const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchMembers() { try { setLoading(true); const response = await getMembers({ skip: 0, limit: 20 }); setMembers(response.items); } catch (err) { setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다.'); } finally { setLoading(false); } } fetchMembers(); }, []); if (loading) return ; if (error) return {error}; return ; } ``` **[Complete Guide: resources/data-fetching.md](resources/data-fetching.md)** --- ### API Client Patterns **Main API Client (`lib/api.ts`):** ```typescript import { api } from '@/lib/api'; // Token management import { getAccessToken, setTokens, clearTokens, hasTokens } from '@/lib/api'; // Auth methods await api.post('/api/v1/auth/login', { phone, password }); await firebaseLogin(idToken); await kakaoLogin(code, redirectUri); // Generic methods const data = await api.get('/api/v1/endpoint'); await api.post('/api/v1/endpoint', body); await api.patch('/api/v1/endpoint', changes); ``` **Admin API Client (`lib/adminApi.ts`):** ```typescript import { // Dashboard getDashboardStats, getRegistrationTrend, getGenderRatio, getReferralStats, // Member Management getMembers, getMemberDetail, updateMemberBasic, updateMemberProfile, updateMemberLifestyle, updateMemberPreference, updateMemberSubscription, exportMembersToExcel, // Consultations getConsultations, createConsultation, updateConsultation, deleteConsultation, // Matching getCandidates, getCompatibilityScore, } from '@/lib/adminApi'; ``` --- ### Constants & Enums Pattern **Define in `constants/enums.ts`:** ```typescript // constants/enums.ts export const USER_STATUS_OPTIONS = [ { value: "draft", label: "상담 전" }, { value: "pending_review", label: "상담 예정" }, { value: "active", label: "상담 완료" }, { value: "suspended", label: "정지" }, { value: "withdrawn", label: "탈퇴" }, ] as const; export const GENDER_OPTIONS = [ { value: "male", label: "남성" }, { value: "female", label: "여성" }, ] as const; // Helper functions export function getEnumLabel( options: readonly { value: string; label: string }[], value: string | null | undefined ): string { if (!value) return "-"; return options.find(o => o.value === value)?.label ?? value; } export function getUserStatusLabel(value: string | null | undefined): string { return getEnumLabel(USER_STATUS_OPTIONS, value); } ``` **Usage in Components:** ```typescript import { USER_STATUS_OPTIONS, getEnumLabel } from '@/constants/enums'; // In Select component // Display label {getEnumLabel(USER_STATUS_OPTIONS, member.status)} ``` --- ### Form Patterns (YGS-Specific) **Pattern 1: Manual State Management (Most Common in YGS):** ```typescript 'use client'; import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Loader2 } from 'lucide-react'; interface FormData { phone: string; name: string; gender: string; birthYear: string; } interface FormErrors { phone?: string; name?: string; gender?: string; birthYear?: string; } export function SignupForm() { const [formData, setFormData] = useState({ phone: '', name: '', gender: '', birthYear: '' }); const [errors, setErrors] = useState({}); const [loading, setLoading] = useState(false); const validateForm = (): boolean => { const newErrors: FormErrors = {}; if (!/^010-\d{4}-\d{4}$/.test(formData.phone)) { newErrors.phone = "올바른 전화번호 형식이 아닙니다."; } if (formData.name.length < 2) { newErrors.name = "이름은 2자 이상이어야 합니다."; } if (!formData.gender) { newErrors.gender = "성별을 선택해주세요."; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleInputChange = useCallback((field: keyof FormData, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); if (errors[field]) { setErrors(prev => ({ ...prev, [field]: undefined })); } }, [errors]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) return; setLoading(true); try { await api.post('/signup', formData); } catch (err) { setErrors({ ...errors, phone: "회원가입에 실패했습니다." }); } finally { setLoading(false); } }; return (
handleInputChange('phone', e.target.value)} placeholder="전화번호" className={cn(errors.phone && "border-red-500")} /> {errors.phone &&

{errors.phone}

}
); } ``` **Pattern 2: Modal Form (Admin Edit):** ```typescript 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { updateMemberBasic } from '@/lib/adminApi'; import type { MemberDetail } from '@/types/admin'; interface EditModalProps { isOpen: boolean; onClose: () => void; onSuccess: (member: MemberDetail) => void; member: MemberDetail; } export function BasicInfoEditModal({ isOpen, onClose, onSuccess, member }: EditModalProps) { const [formData, setFormData] = useState({ name: '', status: '' }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); useEffect(() => { if (isOpen) { setFormData({ name: member.name, status: member.status }); setError(''); } }, [isOpen, member]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(''); try { // Only send changed fields const changes: Record = {}; if (formData.name !== member.name) changes.name = formData.name; if (formData.status !== member.status) changes.status = formData.status; if (Object.keys(changes).length === 0) { onClose(); return; } const result = await updateMemberBasic(member.id, changes); onSuccess(result); onClose(); } catch (err) { setError(err instanceof Error ? err.message : '수정에 실패했습니다.'); } finally { setLoading(false); } }; return ( 기본 정보 수정
setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="이름" /> {error &&

{error}

}
); } ``` --- ### URL-Based State Pattern (Pagination/Filtering) ```typescript 'use client'; import { useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import type { MemberFilter } from '@/types/admin'; export function useMemberFilters() { const searchParams = useSearchParams(); const router = useRouter(); const filters: MemberFilter = { status: searchParams.get('status') || '', gender: searchParams.get('gender') || '', search: searchParams.get('search') || '', skip: Number(searchParams.get('skip')) || 0, limit: Number(searchParams.get('limit')) || 20, }; const updateURL = useCallback((newFilters: Partial) => { const params = new URLSearchParams(); const merged = { ...filters, ...newFilters }; if (merged.status) params.set('status', merged.status); if (merged.gender) params.set('gender', merged.gender); if (merged.search) params.set('search', merged.search); if (merged.skip) params.set('skip', String(merged.skip)); if (merged.limit !== 20) params.set('limit', String(merged.limit)); router.push(`/admin/members${params.toString() ? `?${params}` : ''}`); }, [filters, router]); return { filters, updateURL }; } ``` --- ### Styling **shadcn/ui + Tailwind CSS 4:** - Primary: shadcn/ui pre-built components with Tailwind - Customization: Override with Tailwind utility classes - Class merging: Use `cn()` utility for conditional classes **cn() Utility (IMPORTANT):** ```typescript import { cn } from '@/lib/utils'; // Conditional classes
// Status-based styling const statusColors: Record = { draft: "bg-slate-100 text-slate-600", active: "bg-green-100 text-green-700", suspended: "bg-red-100 text-red-600", }; {getStatusLabel(status)} ``` **YGS Color System:** ```typescript // Primary brand color (Coral/Orange) className="bg-primary text-white" className="hover:bg-primary-dark" className="text-primary" // Gradients className="bg-gradient-to-r from-amber-500 to-orange-500" // Status colors className="text-red-500" // Errors, destructive className="bg-green-50" // Success className="text-gray-500" // Muted ``` **Responsive Patterns:** ```typescript // Grid className="grid grid-cols-1 lg:grid-cols-4 gap-4" className="grid grid-cols-2 lg:grid-cols-4 gap-4" // Text className="text-xl sm:text-2xl md:text-3xl lg:text-4xl" // Display className="hidden md:flex" // Hidden on mobile className="md:hidden" // Mobile only // Padding className="px-4 sm:px-6 md:px-12" ``` **[Complete Guide: resources/styling-guide.md](resources/styling-guide.md)** --- ### File Organization **App Router Structure:** ``` src/ app/ page.tsx # Home page (/) layout.tsx # Root layout {route}/ page.tsx # Route page layout.tsx # Route layout (optional) loading.tsx # Route loading error.tsx # Route error api/ {route}/ route.ts # API route handler components/ ui/ # shadcn/ui components button.tsx card.tsx input.tsx admin/ # Admin-specific components DashboardStats.tsx MemberTable.tsx modals/ # Edit modals {feature}/ # Feature-specific components Component.tsx ``` **Component Organization:** - shadcn/ui components in `src/components/ui/` - Feature components in `src/components/{feature}/` - Admin components in `src/components/admin/` - Keep Server and Client components separate **[Complete Guide: resources/file-organization.md](resources/file-organization.md)** --- ### Loading & Error States **App Router Conventions:** **Loading:** ```typescript // loading.tsx (route-level) import { Skeleton } from '@/components/ui/skeleton'; export default function Loading() { return (
{[...Array(4)].map((_, i) => ( ))}
); } ``` **Error Handling (Korean):** ```typescript // error.tsx (route-level) 'use client'; import { Button } from '@/components/ui/button'; import { AlertCircle } from 'lucide-react'; export default function Error({ error, reset }: { error: Error; reset: () => void }) { return (

오류가 발생했습니다

페이지를 불러오는 중 문제가 발생했습니다.

); } ``` **[Complete Guide: resources/loading-and-error-states.md](resources/loading-and-error-states.md)** --- ### Performance **Next.js 15 Optimizations:** - Server Components by default (zero JS to client) - Dynamic imports: `const Heavy = dynamic(() => import('./Heavy'))` - Image optimization: `next/image` component - Font optimization: Built-in font loading - Turbopack: Faster dev builds (already enabled) **React 19 Patterns:** - `useMemo`: Expensive computations - `useCallback`: Event handlers passed to children - `React.memo`: Prevent unnecessary re-renders **[Complete Guide: resources/performance.md](resources/performance.md)** --- ### TypeScript **Standards:** - Strict mode enabled - Explicit return types on functions - Type imports: `import type { User } from '@/types'` - Component prop interfaces with JSDoc - No `any` type (use `unknown` if needed) **YGS Type Patterns:** ```typescript // types/admin.ts interface AdminMember { id: string; name: string; gender: "male" | "female"; phone: string; status: string; birth_year: number | null; created_at: string; } interface MemberDetail extends AdminMember { profile: UserProfile | null; lifestyle: UserLifestyle | null; preference: UserPreference | null; subscription: UserSubscription | null; documents: UserDocument[]; photos: UserPhoto[]; } // Update request types (partial) interface BasicInfoUpdateRequest { name?: string; status?: string; is_admin?: boolean; birth_year?: number; gender?: "male" | "female"; } ``` **[Complete Guide: resources/typescript-standards.md](resources/typescript-standards.md)** --- ## Navigation Guide | Need to... | Read this resource | |------------|-------------------| | Create a component | [component-patterns.md](resources/component-patterns.md) | | Fetch data | [data-fetching.md](resources/data-fetching.md) | | Organize files/folders | [file-organization.md](resources/file-organization.md) | | Style components | [styling-guide.md](resources/styling-guide.md) | | Set up routing | [routing-guide.md](resources/routing-guide.md) | | Handle loading/errors | [loading-and-error-states.md](resources/loading-and-error-states.md) | | Optimize performance | [performance.md](resources/performance.md) | | TypeScript types | [typescript-standards.md](resources/typescript-standards.md) | | Forms/Auth/API Routes | [common-patterns.md](resources/common-patterns.md) | | See full examples | [complete-examples.md](resources/complete-examples.md) | --- ## Core Principles 1. **Server Components First**: Use Server Components by default, Client Components only for interactivity 2. **Async Data Fetching**: Fetch data directly in Server Components 3. **Minimize Client JS**: Less JavaScript sent to the browser = better performance 4. **App Router Conventions**: Use loading.tsx, error.tsx, layout.tsx appropriately 5. **shadcn/ui Components**: Use pre-built accessible components from `@/components/ui/` 6. **cn() for Classes**: Always use `cn()` for conditional/merged class names 7. **Import with @/ alias**: Consistent import paths across the project 8. **Type Safety**: Strict TypeScript with explicit types 9. **Korean Localization**: All user-facing text in Korean 10. **AuthProvider**: Use `useAuth()` hook for client-side auth state --- ## Quick Reference: Templates ### Server Component Template ```typescript import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { getDashboardStats } from '@/lib/adminApi'; import type { DashboardStats } from '@/types/admin'; import type { Metadata } from 'next'; export const metadata: Metadata = { title: '대시보드 | YGS 관리자', }; export default async function DashboardPage() { const stats: DashboardStats = await getDashboardStats(); return (

대시보드

전체 회원

{stats.total_members}

); } ``` ### Client Component Template ```typescript 'use client'; import { useState, useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { getMembers } from '@/lib/adminApi'; import { cn } from '@/lib/utils'; import { Loader2 } from 'lucide-react'; import type { AdminMember } from '@/types/admin'; interface MemberListProps { className?: string; } export function MemberList({ className }: MemberListProps) { const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchMembers = useCallback(async () => { try { setLoading(true); const response = await getMembers({ skip: 0, limit: 20 }); setMembers(response.items); } catch (err) { setError(err instanceof Error ? err.message : '데이터를 불러오는데 실패했습니다.'); } finally { setLoading(false); } }, []); useEffect(() => { fetchMembers(); }, [fetchMembers]); if (loading) { return (
{[...Array(5)].map((_, i) => ( ))}
); } if (error) { return (

{error}

); } return (
{members.map(member => (

{member.name}

{member.phone}

))}
); } ``` For complete examples, see [resources/complete-examples.md](resources/complete-examples.md) --- ## Related Skills - **error-tracking**: Error tracking with Sentry (applies to frontend too) - **fastapi-backend-guidelines**: Backend API patterns that frontend consumes --- **Skill Status**: Updated for YGS project with comprehensive coverage of actual codebase patterns