13 KiB
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'
'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
// 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:
import 'server-only';
export async function getServerConfig() {
// This will error if accidentally imported from a Client Component
}
Data Fetching
In Server Components: fetch directly
// 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:
'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:
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
// app/(dashboard)/products/loading.tsx
export default function Loading() {
return <ProductGridSkeleton />;
}
Use error.tsx for route-level error boundaries
// 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
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
// 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
// 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
// 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
// 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
'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
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
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:
// 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
// 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/imagefor all images — automatic optimization, lazy loading, responsive sizing - Use
next/linkfor all internal navigation — prefetching, client-side transitions - Lazy load heavy components with
dynamic():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.memofor list items that render frequently (e.g., pantry items, shopping items)
Testing
-
Component tests: React Testing Library
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)