# 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 (

Product Library

{/* Client Component */} {/* Client Component for interactivity */}
); } ``` ### 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 ; } ``` ### 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 ; if (error) return ; return 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 ( <> ); } ``` ## Loading & Error States ### Use `loading.tsx` for route-level loading ```typescript // app/(dashboard)/products/loading.tsx export default function Loading() { return ; } ``` ### 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 (

Something went wrong

{error.message}

); } ``` ### Use `` for granular loading within a page ```typescript import { Suspense } from 'react'; export default function PantryPage() { return (

Pantry

}> }>
); } ``` ## 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 { const session = await getSession(); return { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken}`, }; } async get(url: string): Promise { const res = await fetch(`${BASE_URL}${url}`, { headers: await this.getHeaders(), }); if (!res.ok) throw await this.handleError(res); return res.json(); } async post(url: string, body: unknown): Promise { 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) => apiClient.get>(`/products?${new URLSearchParams(params)}`), getById: (id: string) => apiClient.get(`/products/${id}`), create: (data: CreateProductDto) => apiClient.post('/products', data), update: (id: string, data: Partial) => apiClient.patch(`/products/${id}`, data), }; ``` ## Layouts & Navigation ### Root layout: providers and global UI ```typescript // app/layout.tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### Dashboard layout: sidebar + topbar ```typescript // app/(dashboard)/layout.tsx export default function DashboardLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ``` ### 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.formState.errors.name && {form.formState.errors.name.message}} {/* ... */}
); } ``` ### 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: () => , }); ``` - **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(); 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)