Setup initial project
This commit is contained in:
commit
db79af06f7
119 changed files with 20761 additions and 0 deletions
470
docs/instructions/nextjs.md
Normal file
470
docs/instructions/nextjs.md
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
# 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue