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:
- Backend: model + migration + controller + route + tests
- Backend: deploy (additive change; safe)
- Dashboard: TS types + API module + normalizer + TanStack Query hook
- Dashboard: UI changes
- Dashboard: deploy
- (Optional) Widget: consume new field if it’s payload-shaped
- (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+auditgroup - Premium-only — add
subscription_tier:premium - Admin-only —
/adminprefix +role:admin - Public + token-keyed (widget) — under
/extprefix, noauth: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 adataenvelope for resources - Use
findOrFailso 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 deployThis 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-previewStep 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 mainVercel 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:
- Backend: ensure the field is part of the
designJSON column when the Finder is updated - Widget: read it from the design payload via the stores (
src/lib/stores.js) - Widget: render conditionally
- 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
auditmiddleware 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
undefineduntil the next deploy
Verification checklist
- Endpoint returns expected shape under
curlin production - Audit log row appears after mutation (check
audit_logstable) - 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
- Add a feature confined to one service: add-feature-frontend, add-feature-backend, add-feature-widget
- Add a customization the user can toggle: add-customization-option
- Local environment setup: run-locally