---
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 (
);
}
```
**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 (
{/* Always render static content */}
{/* Auth-dependent content only after mount */}
{mounted && !isLoading ? (
isAuthenticated ? :
) : (
// Placeholder
)}
);
}
```
---
### 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
{USER_STATUS_OPTIONS.map(option => (
{option.label}
))}
// 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 (
);
}
```
**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 (
기본 정보 수정
);
}
```
---
### 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 (
);
}
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