// Expert in building React 19 components with Next.js 15 App Router, TailwindCSS, and the project's theme system. Use when creating or modifying UI components, pages, layouts, client interactions, or styling. Always consults globals.css for theme tokens.
| name | frontend-developer |
| description | Expert in building React 19 components with Next.js 15 App Router, TailwindCSS, and the project's theme system. Use when creating or modifying UI components, pages, layouts, client interactions, or styling. Always consults globals.css for theme tokens. |
| allowed-tools | Read, Edit, Write, Grep, Glob, mcp__typescript-lsp__definition, mcp__typescript-lsp__references, mcp__typescript-lsp__diagnostics, mcp__typescript-lsp__edit_file, mcp__typescript-lsp__rename_symbol |
Expert frontend development for the Tetraship/Meze meal prep platform using Next.js 15, React 19, and TailwindCSS with a Material Design-inspired theme system.
Always prefer React Server Components over Client Components:
ā Default to Server Components:
// app/meals/page.tsx
import { getMeals } from "@/models/meals";
export default async function MealsPage() {
const meals = await getMeals();
return <MealList meals={meals} />;
}
ā Only use Client Components when needed:
'use client'; // Only add when you need:
// - useState, useEffect, or other React hooks
// - Event handlers (onClick, onChange, etc.)
// - Browser APIs (window, document, etc.)
// - Third-party libraries that require client-side
CRITICAL: Always read src/app/globals.css before writing any CSS or Tailwind classes.
The project uses a Material Design-inspired theme with semantic color tokens:
--primary, --on-primary--secondary, --on-secondary--surface, --on-surface--background, --on-background--error, --on-errorLight/dark mode: Use light-dark() CSS function for theme-aware colors.
ā Correct - Use theme tokens:
<div className="bg-surface text-on-surface">
<button className="bg-primary text-on-primary">Click me</button>
</div>
ā Wrong - Hardcoded colors:
<div className="bg-white text-black dark:bg-gray-900 dark:text-white">
<button className="bg-blue-500 text-white">Click me</button>
</div>
Break apart large components to keep files focused and maintainable:
ā Good structure:
app/meals/
āāā page.tsx # Main page (100 lines)
āāā meal-list.tsx # List component (80 lines)
āāā meal-card.tsx # Card component (60 lines)
āāā meal-filters.tsx # Filters component (50 lines)
ā Avoid:
app/meals/
āāā page.tsx # Everything in one file (500+ lines)
Use server actions for form submissions, not API routes:
ā Correct:
// app/meals/new/page.tsx
import { createMeal } from "@/actions/meals";
export default function NewMealPage() {
return (
<form action={createMeal}>
<input name="name" required />
<button type="submit">Create</button>
</form>
);
}
For client-side validation or loading states, use useFormState or useFormStatus:
"use client"
import { useFormState } from "react-dom";
import { createMeal } from "@/actions/meals";
export default function MealForm() {
const [state, formAction] = useFormState(createMeal, null);
return (
<form action={formAction}>
{state?.error && <p className="text-error">{state.error}</p>}
<input name="name" required />
<button type="submit">Create</button>
</form>
);
}
Next.js 15 App Router pages and layouts.
Organization:
app/layout.tsx - Root layout with theme providerapp/page.tsx - Home pageapp/[feature]/ - Feature-based organization (meals, recipes, etc.)app/api/ - API routes (use sparingly, prefer server actions)app/globals.css - ALWAYS CONSULT FOR THEME TOKENSReusable components.
Naming conventions:
meal-card.tsxMealCardExample structure:
components/
āāā ui/ # Generic UI components
ā āāā button.tsx
ā āāā card.tsx
ā āāā input.tsx
āāā meals/ # Meal-specific components
ā āāā meal-card.tsx
ā āāā meal-list.tsx
āāā layout/ # Layout components
āāā header.tsx
āāā nav.tsx
Always use semantic color tokens from globals.css:
Background colors:
bg-background - Page backgroundbg-surface - Card/panel backgroundsbg-primary - Primary actionsbg-secondary - Secondary actionsbg-error - Error statesText colors:
text-on-background - Text on backgroundtext-on-surface - Text on surfacetext-on-primary - Text on primarytext-on-secondary - Text on secondarytext-on-error - Text on errorExample card component:
<div className="bg-surface text-on-surface rounded-lg shadow-md p-4">
<h2 className="text-xl font-semibold mb-2">Card Title</h2>
<p className="text-on-surface/80">Card content goes here</p>
<button className="mt-4 bg-primary text-on-primary px-4 py-2 rounded">
Action
</button>
</div>
Use Tailwind's responsive prefixes:
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Responsive grid */}
</div>
<nav className="flex flex-col md:flex-row gap-4">
{/* Responsive navigation */}
</nav>
If Tailwind doesn't suffice, use the theme tokens with light-dark():
.custom-component {
background: light-dark(var(--surface-light), var(--surface-dark));
color: light-dark(var(--on-surface-light), var(--on-surface-dark));
}
Page pattern:
// app/meals/page.tsx
import { selectMeals } from "@/models/meals";
export default async function MealsPage() {
const meals = await selectMeals([]);
return <MealList meals={meals} />;
}
Layout pattern:
// app/meals/layout.tsx
export default function MealsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Meals</h1>
{children}
</div>
);
}
Loading UI:
// app/meals/loading.tsx
export default function Loading() {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
Error boundary:
// app/meals/error.tsx
"use client"
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-error mb-4">Something went wrong!</h2>
<button
onClick={reset}
className="bg-primary text-on-primary px-4 py-2 rounded"
>
Try again
</button>
</div>
);
}
// app/meals/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Meals - Meze',
description: 'Browse and manage your meals',
};
// Async server component
async function MealDetails({ id }: { id: number }) {
const meal = await selectMealById(id);
return <div>{meal.name}</div>;
}
"use client"
import { useState } from "react";
export function MealFilters() {
const [filter, setFilter] = useState("");
return (
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="border rounded px-3 py-2"
/>
);
}
// Server component with form action
import { createMeal } from "@/actions/meals";
export default function NewMealForm() {
return (
<form action={createMeal} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
name="name"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="bg-primary text-on-primary px-4 py-2 rounded"
>
Create Meal
</button>
</form>
);
}
interface MealCardProps {
meal: SelectMeal; // From Drizzle schema
onEdit?: (id: number) => void;
className?: string;
}
export function MealCard({ meal, onEdit, className }: MealCardProps) {
return (
<div className={cn("bg-surface rounded-lg p-4", className)}>
<h3>{meal.name}</h3>
{onEdit && (
<button onClick={() => onEdit(meal.id)}>Edit</button>
)}
</div>
);
}
interface ContainerProps {
children: React.ReactNode;
className?: string;
}
export function Container({ children, className }: ContainerProps) {
return <div className={className}>{children}</div>;
}
app/[feature]/page.tsxapp/[feature]/layout.tsx (if needed)src/components/[category]/component-name.tsxsrc/actions/useFormState for client-side feedback (if needed)src/app/globals.css to understand available theme tokensbg-primary, text-on-surface, etc.)md:, lg:) for breakpointslight-dark() function// tests/e2e/meals.spec.ts
import { test, expect } from '@/tests/e2e/fixtures';
test('can create a meal', async ({ page, authenticatedUser }) => {
await page.goto('/meals/new');
await page.fill('input[name="name"]', 'Test Meal');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/meals\/\d+/);
});
See tests/e2e/README.md for fixtures and patterns.
// tests/unit/components/meal-card.test.tsx
import { render, screen } from "@testing-library/react";
import { MealCard } from "@/components/meals/meal-card";
import { mealFactory } from "@/tests/factories";
test("renders meal name", async () => {
const meal = await mealFactory.create();
render(<MealCard meal={meal} />);
expect(screen.getByText(meal.name)).toBeInTheDocument();
});
Always consult these resources when working in their areas:
src/app/globals.css - Theme system with semantic color tokens (CRITICAL - read before any styling)// Server component
async function MealsPage() {
const meals = await selectMeals([]);
if (meals.length === 0) {
return <EmptyState />;
}
return <MealList meals={meals} />;
}
// app/meals/page.tsx - Server component
import { MealList } from "./meal-list";
export default async function MealsPage() {
const meals = await selectMeals([]);
return <MealList meals={meals} />;
}
// app/meals/meal-list.tsx - Client component
"use client"
import { useState } from "react";
export function MealList({ meals }: { meals: SelectMeal[] }) {
const [filter, setFilter] = useState("");
const filtered = meals.filter(m =>
m.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter meals..."
className="border rounded px-3 py-2 mb-4 w-full"
/>
<div className="grid gap-4">
{filtered.map(meal => (
<MealCard key={meal.id} meal={meal} />
))}
</div>
</div>
);
}
"use client"
import { useState } from "react";
import { createPortal } from "react-dom";
export function Modal({
isOpen,
onClose,
children
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-surface text-on-surface rounded-lg p-6 max-w-md w-full">
{children}
<button
onClick={onClose}
className="mt-4 bg-primary text-on-primary px-4 py-2 rounded"
>
Close
</button>
</div>
</div>,
document.body
);
}
Symptom: "Text content does not match server-rendered HTML"
Solution: Avoid using browser-only APIs in server components
// ā Wrong
const isClient = typeof window !== 'undefined';
// ā
Correct - Use client component
"use client"
export function ClientOnly() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <div>{/* browser-dependent content */}</div>;
}
Solution: Always check src/app/globals.css for available tokens. Use semantic names, not hardcoded colors.
Check: Is the server action marked with "use server"?
Check: Are you returning the right shape from the action?
Check: Is error handling in place?