MeshiTrack/docs/instructions/nextjs.md

471 lines
13 KiB
Markdown
Raw Normal View History

2026-03-27 14:50:34 +09:00
# Next.js Best Practices — MeshiTrack Web
> Instruction file for developing the Next.js frontend (`packages/web`).
> Uses **App Router** (not Pages Router), **TypeScript**, and **Tailwind CSS**.
## Project Structure
```
packages/web/src/
├── app/ # App Router — file-based routing
│ ├── layout.tsx # Root layout (html, body, providers)
│ ├── page.tsx # Dashboard / home page
│ ├── loading.tsx # Root loading state
│ ├── error.tsx # Root error boundary
│ ├── not-found.tsx # 404 page
│ ├── (auth)/ # Route group: unauthenticated pages
│ │ ├── login/page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/ # Route group: authenticated pages
│ │ ├── layout.tsx # Sidebar + topbar layout
│ │ ├── products/
│ │ │ ├── page.tsx # Product list
│ │ │ ├── [id]/page.tsx # Product detail
│ │ │ └── loading.tsx
│ │ ├── recipes/
│ │ ├── pantry/
│ │ ├── meal-plans/
│ │ ├── shopping-lists/
│ │ └── settings/
│ └── api/ # Route Handlers (if needed for BFF patterns)
├── components/ # Shared React components
│ ├── ui/ # Generic UI components (Button, Modal, Card, etc.)
│ ├── forms/ # Form components
│ ├── layout/ # Navigation, Sidebar, TopBar
│ └── features/ # Feature-specific composed components
│ ├── products/
│ ├── recipes/
│ ├── pantry/
│ └── shopping/
├── hooks/ # Custom React hooks
├── services/ # API client layer
│ ├── api-client.ts # Configured fetch/axios wrapper
│ ├── products.service.ts
│ ├── recipes.service.ts
│ └── ...
├── lib/ # Utility functions, constants
├── styles/ # Global styles, Tailwind config
└── types/ # Frontend-specific types (import shared types from @meshitrack/shared)
```
## Server vs Client Components
### Default to Server Components
Every component in the App Router is a **Server Component** by default. Keep it that way unless the component needs:
- Browser APIs (`window`, `document`, `localStorage`)
- React hooks (`useState`, `useEffect`, `useRef`, etc.)
- Event handlers (`onClick`, `onChange`, etc.)
- Browser-only libraries
### Mark Client Components explicitly with `'use client'`
```typescript
'use client';
import { useState } from 'react';
export function ProductSearchBar({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
// ...interactive UI
}
```
### Composition pattern: Server parent, Client children
```typescript
// app/(dashboard)/products/page.tsx — Server Component
import { ProductSearchBar } from '@/components/features/products/ProductSearchBar';
import { ProductList } from '@/components/features/products/ProductList';
export default async function ProductsPage() {
// Can fetch data directly on the server
const initialProducts = await fetchProducts();
return (
<div>
<h1>Product Library</h1>
<ProductSearchBar /> {/* Client Component */}
<ProductList initialData={initialProducts} /> {/* Client Component for interactivity */}
</div>
);
}
```
### Never import server-only code in Client Components
If a utility should only run on the server, use the `server-only` package:
```typescript
import 'server-only';
export async function getServerConfig() {
// This will error if accidentally imported from a Client Component
}
```
## Data Fetching
### In Server Components: fetch directly
```typescript
// app/(dashboard)/products/page.tsx
export default async function ProductsPage() {
const res = await fetch(`${process.env.API_URL}/api/v1/products`, {
headers: { Authorization: `Bearer ${await getToken()}` },
cache: 'no-store', // Always fresh for user-specific data
});
const data = await res.json();
return <ProductGrid products={data.data} />;
}
```
### In Client Components: use SWR or React Query
We recommend **SWR** for most data fetching in Client Components:
```typescript
'use client';
import useSWR from 'swr';
import { apiClient } from '@/services/api-client';
export function PantryDashboard() {
const { data, error, isLoading, mutate } = useSWR(
'/api/v1/pantry?sort=-freshnessEstimate.daysRemaining',
apiClient.get,
);
if (isLoading) return <PantrySkeleton />;
if (error) return <ErrorDisplay error={error} />;
return <PantryGrid items={data.data} onUpdate={() => mutate()} />;
}
```
### Parallel data fetching
When a page needs multiple independent data sources, fetch in parallel:
```typescript
export default async function DashboardPage() {
const [pantryData, mealPlanData, shoppingData] = await Promise.all([
fetchExpiringSoon(),
fetchCurrentMealPlan(),
fetchActiveShoppingLists(),
]);
return (
<>
<ExpiringItems items={pantryData} />
<CurrentMealPlan plan={mealPlanData} />
<ActiveShoppingLists lists={shoppingData} />
</>
);
}
```
## Loading & Error States
### Use `loading.tsx` for route-level loading
```typescript
// app/(dashboard)/products/loading.tsx
export default function Loading() {
return <ProductGridSkeleton />;
}
```
### Use `error.tsx` for route-level error boundaries
```typescript
// app/(dashboard)/products/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
```
### Use `<Suspense>` for granular loading within a page
```typescript
import { Suspense } from 'react';
export default function PantryPage() {
return (
<div>
<h1>Pantry</h1>
<Suspense fallback={<FreshnessAlertsSkeleton />}>
<FreshnessAlerts />
</Suspense>
<Suspense fallback={<PantryGridSkeleton />}>
<PantryGrid />
</Suspense>
</div>
);
}
```
## API Client Layer
### Centralized API client with auth
```typescript
// services/api-client.ts
import { getSession } from '@/lib/auth';
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
class ApiClient {
private async getHeaders(): Promise<HeadersInit> {
const session = await getSession();
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${session?.accessToken}`,
};
}
async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
headers: await this.getHeaders(),
});
if (!res.ok) throw await this.handleError(res);
return res.json();
}
async post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
method: 'POST',
headers: await this.getHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) throw await this.handleError(res);
return res.json();
}
// ... patch, delete, upload methods
}
export const apiClient = new ApiClient();
```
### Feature-specific service files
```typescript
// services/products.service.ts
import { apiClient } from './api-client';
import type { Product, PaginatedResponse, CreateProductDto } from '@meshitrack/shared';
export const productsService = {
list: (params?: Record<string, string>) =>
apiClient.get<PaginatedResponse<Product>>(`/products?${new URLSearchParams(params)}`),
getById: (id: string) => apiClient.get<Product>(`/products/${id}`),
create: (data: CreateProductDto) => apiClient.post<Product>('/products', data),
update: (id: string, data: Partial<CreateProductDto>) =>
apiClient.patch<Product>(`/products/${id}`, data),
};
```
## Layouts & Navigation
### Root layout: providers and global UI
```typescript
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}
```
### Dashboard layout: sidebar + topbar
```typescript
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col">
<TopBar />
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</div>
</div>
);
}
```
### Use route groups `(folder)` for shared layouts
Route groups (parenthesized folder names) don't affect the URL:
- `(auth)` — login, register pages with minimal layout
- `(dashboard)` — all authenticated pages with full navigation
## Forms
### Use controlled forms with validation
```typescript
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateProductSchema } from '@meshitrack/shared';
export function ProductForm({ onSubmit }: { onSubmit: (data: CreateProductInput) => void }) {
const form = useForm({
resolver: zodResolver(CreateProductSchema),
defaultValues: { name: '', category: '', servingSize: 0, ... },
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')} />
{form.formState.errors.name && <span>{form.formState.errors.name.message}</span>}
{/* ... */}
</form>
);
}
```
### Optimistic updates for real-time feel
```typescript
const { trigger, isMutating } = useSWRMutation('/api/v1/pantry/item/transition', apiClient.post);
async function handleConsume(itemId: string) {
// Optimistically update local data
mutate(
(currentData) => ({
...currentData,
data: currentData.data.map((item) =>
item.id === itemId ? { ...item, status: 'consumed' } : item,
),
}),
false,
);
// Then send to server
await trigger({ itemId, status: 'consumed' });
}
```
## Shared Types from `@meshitrack/shared`
### Import types from the shared package
```typescript
import type { Product, NutritionInfo, ProductCategory } from '@meshitrack/shared';
import { ServingUnit, ProductSource } from '@meshitrack/shared';
```
### Never duplicate types in the web package
If a type is used in both API and web, it **must** live in `packages/shared`. The web package only defines frontend-specific types (e.g., UI state, component props).
## Authentication (Keycloak)
### Use `next-auth` or `keycloak-js` for OIDC
For App Router, `next-auth` v5 with the Keycloak provider is recommended:
```typescript
// lib/auth.ts
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
return session;
},
},
});
```
### Protect routes with middleware
```typescript
// proxy.ts (Next.js 16 renamed middleware.ts → proxy.ts)
export { auth as proxy } from '@/lib/auth';
export const config = {
matcher: ['/(dashboard)/:path*'], // Protect all dashboard routes
};
```
## Performance Tips
- **Use `next/image`** for all images — automatic optimization, lazy loading, responsive sizing
- **Use `next/link`** for all internal navigation — prefetching, client-side transitions
- **Lazy load heavy components** with `dynamic()`:
```typescript
import dynamic from 'next/dynamic';
const PriceChart = dynamic(() => import('@/components/features/prices/PriceChart'), {
loading: () => <ChartSkeleton />,
});
```
- **Keep Client Components as small as possible** — push `'use client'` boundary as far down the tree as you can
- **Use `React.memo`** for list items that render frequently (e.g., pantry items, shopping items)
## Testing
- **Component tests**: React Testing Library
```typescript
import { render, screen } from '@testing-library/react';
import { ProductCard } from '@/components/features/products/ProductCard';
test('displays product name and calories', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
expect(screen.getByText('165 kcal')).toBeInTheDocument();
});
```
- **E2E tests**: Playwright for critical flows
- **Mock API calls** in tests using MSW (Mock Service Worker)