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 an API endpoint end-to-end

The canonical worked example for any change that touches more than one service. This recipe wires a new endpoint through Backend → Dashboard, with optional stops at Widget consumption and CDN payload regeneration.

We’ll use a hypothetical example: adding a GET /finders/{id}/duplicate-preview endpoint that returns a preview of what duplicating a finder would produce, without committing the duplicate.

Order of operations

Always follow this order, even if it means multiple PRs:

  1. Backend: model + migration + controller + route + tests
  2. Backend: deploy (additive change; safe)
  3. Dashboard: TS types + API module + normalizer + TanStack Query hook
  4. Dashboard: UI changes
  5. Dashboard: deploy
  6. (Optional) Widget: consume new field if it’s payload-shaped
  7. (Optional) Widget: deploy

The cardinal rule: the read side (consumer) is deployed after the write side (producer) when adding a field, and before the write side when removing one. Backwards compatibility lives in the gap between deploys.

⚠️ Warning: Don’t merge the dashboard PR before the backend deploy lands. The dashboard will start calling an endpoint that 404s in production.

Step 1 — Backend: route

Add to dropafinder-app-backend/routes/api.php, in the right group (almost always inside Route::middleware(['auth:api', 'audit'])->group(...)):

Route::get('finders/{id}/duplicate-preview', [FinderController::class, 'duplicatePreview']);

Pick the right middleware based on access:

  • Authenticated user (most cases) — auth:api + audit group
  • Premium-only — add subscription_tier:premium
  • Admin-only — /admin prefix + role:admin
  • Public + token-keyed (widget) — under /ext prefix, no auth:api

If the endpoint is mutating, leave audit in the chain so the action gets logged.

Step 2 — Backend: controller method

Add the method to app/Http/Controllers/FinderController.php:

public function duplicatePreview(Request $request, $id) { $finder = Finder::findOrFail($id); $this->authorize('view', $finder); return response()->json([ 'data' => [ 'title' => $finder->title . ' (copy)', 'location_count' => $finder->locations()->count(), // ... other preview fields ], ]); }

Conventions to follow (per the codebase patterns visible in FinderController.php):

  • Return response()->json([...]) with a data envelope for resources
  • Use findOrFail so missing ids return 404 automatically
  • Authorize via Laravel policies if the resource has one
  • Workspace-scope where applicable (use the request’s active workspace id)

Step 3 — Backend: tests (if test suite exists)

🔴 [NEEDS CLARIFICATION: Confirm whether the backend currently has feature tests for new endpoints, or whether tests are aspirational only. PHPUnit is configured (phpunit.xml) but suite size is unclear.]

If a feature test pattern exists, add a test that hits the route under auth:api and asserts the response shape.

Step 4 — Backend: deploy

cd /Users/codydavis/Local Sites/dropafinder-app-backend make deploy

This pushes via SSH to Cloudways, runs migrations (none needed for this example), and re-caches config/routes/views.

Verify in production:

curl -H "Authorization: Bearer $TOKEN" https://api.locationfinders.com/api/finders/1/duplicate-preview

Step 5 — Dashboard: TypeScript type

Add to dropafinder-app-nextjs/src/types/finders.ts (or the closest existing type module):

export interface FinderDuplicatePreview { title: string; locationCount: number; }

Naming: domain types are camelCase. The backend resource location_count becomes locationCount after normalization.

Step 6 — Dashboard: normalizer

If the endpoint returns a resource shape that differs from the wire format, add a normalizer in src/lib/api/normalizers.ts:

export function normalizeFinderDuplicatePreview(raw: any): FinderDuplicatePreview { return { title: raw.title, locationCount: raw.location_count, }; }

Per AGENTS.md, every API response goes through a normalizer before reaching components. Don’t shortcut this even for one-field responses — it keeps the wire/domain seam consistent.

Step 7 — Dashboard: API module

Add a function to src/lib/api/finders.ts:

import { fetchAPI } from './core'; import { normalizeFinderDuplicatePreview } from './normalizers'; export async function getFinderDuplicatePreview(id: string) { const res = await fetchAPI(`/finders/${id}/duplicate-preview`); return normalizeFinderDuplicatePreview(res.data); }

fetchAPI handles the bearer token, audit headers (none on a GET), and workspace scoping automatically. Do not call fetch directly.

Step 8 — Dashboard: TanStack Query hook

Convention: place the hook next to the component that uses it, or in a per-resource useFinder.ts if multiple components share it:

import { useQuery } from '@tanstack/react-query'; import { getFinderDuplicatePreview } from '@/lib/api/finders'; export function useFinderDuplicatePreview(id: string, enabled = true) { return useQuery({ queryKey: ['finders', id, 'duplicate-preview'], queryFn: () => getFinderDuplicatePreview(id), enabled, }); }

Cache key conventions: start with the plural resource name, then id, then sub-resource. Mutations elsewhere in the app should invalidate ['finders', id] (which catches everything under that finder).

Step 9 — Dashboard: UI

Wire the hook into the relevant component. For the duplicate-preview example, this might be a modal opened from the Finders list:

const { data, isLoading, error } = useFinderDuplicatePreview(finderId, isModalOpen); if (error) { // surface via useUiStore alert per AGENTS.md }

Surface errors via useUiStore rather than rendering inline error UIs — that keeps error handling consistent across the app.

Step 10 — Dashboard: deploy

cd /Users/codydavis/Local Sites/dropafinder-app-nextjs git push origin main

Vercel auto-deploys. Verify the new flow in production by exercising the UI path.

(Optional) Step 11 — Widget consumption

If your endpoint adds a field that the visitor-facing widget should consume, you have two paths:

Via the finder design payload

If the field belongs in Finder.design, write it server-side when the Finder is saved, then read it in the widget:

  1. Backend: ensure the field is part of the design JSON column when the Finder is updated
  2. Widget: read it from the design payload via the stores (src/lib/stores.js)
  3. Widget: render conditionally
  4. Widget: deploy via npm run deploy:02

Via the /ext/ route group

If the data isn’t part of the static design (e.g., it’s per-visitor), add an endpoint under /ext/finders/details/{key}/... that the widget can call directly.

🔴 [NEEDS CLARIFICATION: Where in the backend does CDN payload regeneration happen on Finder save? Adding a new design field means understanding what triggers the R2 write.]

Common mistakes

  • Forgot audit middleware on a mutation → no audit log row, harder to debug later
  • Forgot to normalize → backend snake_case leaking into components, type drift
  • New endpoint not in reference/api/index → catalog goes stale
  • Backend deploy after dashboard deploy → 404s in production
  • JSON column field added without coordinating with the widget consumer → widget reads undefined until the next deploy

Verification checklist

  • Endpoint returns expected shape under curl in production
  • Audit log row appears after mutation (check audit_logs table)
  • Dashboard UI exercises the path; no console errors
  • TanStack Query cache invalidates correctly after mutation
  • reference/api/index updated
  • Per-resource reference/api/{resource} page updated

Where to next