470 lines
13 KiB
Markdown
470 lines
13 KiB
Markdown
# 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)
|