MeshiTrack/docs/instructions/nextjs.md

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/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():
    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

    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)