These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

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 console

New 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.scss

CSS 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 dev

Run lint before committing:

npm run lint

There 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 main

Vercel 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 use fetchAPI from src/lib/api/core.ts.
  • Skipping the normalizercreated_at snake_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