How to — add a feature (frontend)
Recipe for adding a feature confined to the Next.js dashboard (dropafinder-app-nextjs). For features that also need backend changes, start with add-feature-backend first — deploy the backend before building the UI against it.
Step 1 — Decide where it lives
The app uses a nested route structure under src/app/app/:
src/app/app/
├── finders/ # Finder list + builder pages
├── locations/ # Location management
├── sets/ # Set management
├── maps/ # Map configuration
├── tags/ # Tag management
├── custom-fields/ # Custom field definitions
├── account/ # User account settings
├── billing/ # Paddle billing
└── admin/ # Admin-only consoleNew top-level features get their own directory here (e.g., src/app/app/reports/). Sub-features of an existing resource (e.g., a new tab inside the finder detail page) live within the existing directory.
Create the page file:
src/app/app/reports/page.tsx💡 Tip: Pages under
src/app/app/are protected by the app layout’s auth guard — no per-page auth check needed.
Step 2 — Add TypeScript types
Add domain types to src/types/ (or the closest per-domain type module). Use interface for object shapes; type for unions:
// src/types/reports.ts
export interface Report {
id: number;
title: string;
createdAt: string;
}Use import type everywhere types are imported — never bare import:
import type { Report } from '@/types/reports';Step 3 — Add the API module
Create src/lib/api/reports.ts. Use fetchAPI from core.ts — never call fetch() directly:
import { fetchAPI } from './core';
import type { Report } from '@/types/reports';
import { type RawReport, normalizeReport } from './normalizers';
export const getReports = () =>
fetchAPI<RawReport[]>('reports').then((records) => records.map(normalizeReport));
export const createReport = (data: Partial<Report>) =>
fetchAPI<RawReport>('reports', {
method: 'POST',
auditAction: 'reports.create',
auditResourceType: 'report',
body: JSON.stringify(data),
}).then(normalizeReport);fetchAPI handles the bearer token, workspace scoping header, and audit headers automatically via src/lib/api/core.ts.
Step 4 — Add normalizer and payload transformer
All API responses go through a normalizer before reaching components. Add to src/lib/api/normalizers.ts:
export type RawReport = { id: number; title: string; created_at: string };
export function normalizeReport(raw: RawReport): Report {
return {
id: raw.id,
title: raw.title,
createdAt: raw.created_at,
};
}If the feature writes back to the API, add a payload transformer to src/lib/api/payloads.ts that converts camelCase domain objects to the snake_case wire format.
Per AGENTS.md: every API response goes through a normalizer, every write goes through a payload transformer. Don’t shortcut this for single-field resources.
Step 5 — Wire a TanStack Query hook
Query hooks go next to the component that uses them, or in a per-resource file (e.g., src/hooks/useReports.ts) when multiple components share the same data.
Cache key convention: [pluralResource] for lists, [pluralResource, id] for single resources, [pluralResource, id, subResource] for nested data (per AGENTS.md):
import { useQuery } from '@tanstack/react-query';
import { getReports } from '@/lib/api/reports';
export function useReports() {
return useQuery({
queryKey: ['reports'],
queryFn: getReports,
});
}For mutations, always invalidate the relevant query keys in onSuccess:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createReport } from '@/lib/api/reports';
export function useCreateReport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createReport,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reports'] });
},
});
}Step 6 — Build the UI component
Create the component under src/components/:
src/components/reports/ReportList.tsx
src/components/reports/ReportList.module.scssCSS naming follows BEM (per AGENTS.md). Color tokens use $v2- SCSS variables or var(--color-primary) custom properties — never hardcoded hex values.
Wire the hook in the component:
const { data: reports, isLoading, error } = useReports();Step 7 — Surface errors via useUiStore
Do not use local error state or console.error as a substitute for user-facing feedback. Use addAlert from useUiStore:
import { useUiStore } from '@/stores/uiStore';
const addAlert = useUiStore((state) => state.addAlert);
// in the mutation's onError or catch block:
addAlert('Failed to create report. Please try again.', 'error');addAlert(message, type, duration) — type is 'success' | 'error' | 'info'; duration defaults to 4000 ms.
Step 8 — Add navigation entry (if applicable)
If the feature is a top-level section, add a navigation link. The main nav is declared in the app layout component — search for the nav link list in src/app/app/layout.tsx or a shared Sidebar component under src/components/layout/.
Step 9 — Verify locally
cd "/Users/codydavis/Local Sites/dropafinder-app-nextjs"
npm run devRun lint before committing:
npm run lintThere is no test runner configured in the dashboard (package.json has no test script). See test for the current testing posture.
Step 10 — Deploy
git push origin mainVercel auto-deploys on push to main. See deploy for the full ritual and how to verify the deploy succeeded.
Common mistakes
- Calling
fetch()directly → no auth headers, no workspace scoping. Always usefetchAPIfromsrc/lib/api/core.ts. - Skipping the normalizer →
created_atsnake_case leaks into components; type drift accumulates. - Mutating without invalidating cache → stale data persists in the UI until page reload.
- Using local error state instead of
addAlert→ inconsistent error display across the app. - Hardcoded hex values in SCSS → breaks theming; use the token variables.
Where to next
- Backend setup for this feature: add-feature-backend
- Widget changes if needed: add-feature-widget
- Ship to production: deploy