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

Integration — Next.js → Backend

Every HTTP call from the Next.js dashboard to the Laravel backend passes through a single client module at src/lib/api/core.ts. This page documents the full wire contract: base URL configuration, the fetchAPI client, authentication, audit headers, workspace scoping, error handling, and how the client integrates with TanStack Query.

For a one-line summary of this boundary, see ./index.md. For session management and JWT issuance, see ../../architecture/auth.md. For the Zustand stores referenced here, see ../../codebases/nextjs/state-management.md.


Base URL configuration

core.ts:10 exports two constants:

export const API_BASE = process.env.NEXT_PUBLIC_API_URL!; export const EXT_BASE = `${API_BASE}ext/`;

NEXT_PUBLIC_API_URL is the only env var that controls where all dashboard traffic goes. In production it is set to https://api.locationfinders.com/api/ (trailing slash included). In local development, set it to http://localhost:8000/api/ in .env.local.

EXT_BASE is not a separate deployment — it is the same Laravel app under the /ext/ route group, which handles calls that originate from both the dashboard and the widget script. See ../../codebases/backend/routing.md for the route group split.

⚠️ Warning: The env var name begins with NEXT_PUBLIC_, which means Next.js inlines its value into the client bundle at build time. There is no runtime substitution. Changing it requires a rebuild.


The fetchAPI client

Exported functions

core.ts exports two public wrappers:

// src/lib/api/core.ts:158-164 export function fetchAPI<T>(endpoint: string, options: AuditRequestOptions = {}) { return fetchJson<T>(API_BASE, endpoint, options); } export function fetchExt<T>(endpoint: string, options: AuditRequestOptions = {}) { return fetchJson<T>(EXT_BASE, endpoint, options); }

fetchAPI calls the main API routes; fetchExt calls the /ext/ route group. Both delegate to the internal fetchJson<T> function (core.ts:127).

Per AGENTS.md, never call fetch() directly in the dashboard. All HTTP calls must go through fetchAPI or fetchExt so that auth, audit headers, and workspace scoping are applied uniformly.

AuditRequestOptions type

fetchAPI / fetchExt accept an AuditRequestOptions argument that extends the standard RequestInit:

// core.ts:13-18 export type AuditRequestOptions = RequestInit & { auditAction?: string; auditResourceType?: string; auditResourceId?: string | number; auditRequestId?: string; };

The four audit* fields are consumed by buildAuditHeaders (see Audit headers) before the request fires. They are stripped from the final RequestInit object (core.ts:113-125) so they do not reach the fetch() call.

Content-Type injection

fetchJson sets Content-Type: application/json automatically when the body is present and is not a FormData instance (core.ts:135-137). File upload endpoints (e.g. locations/{id}/image, locations/import) pass a FormData body and rely on the browser to set the correct multipart boundary.


Authentication

JWT source

getToken() (core.ts:20-22) reads the JWT synchronously from authStore using Zustand’s .getState() accessor — the store-outside-React pattern that avoids React rendering context:

function getToken(): string | null { return useAuthStore.getState().token; }

authStore is a Zustand persist store (src/stores/authStore.ts) that serialises to localStorage under the key "user" (the same key the Svelte widget app uses, so both apps share the session). The token field holds a JWT issued by AuthController::login via tymon/jwt-auth — the backend’s auth:api guard is configured as a JWT guard, not Sanctum.

Authorization header

When a token exists, fetchJson attaches it as a Bearer header (core.ts:131-133):

Authorization: Bearer <token>

Unauthenticated requests (no token in store) are sent without an Authorization header. The backend returns 401 Unauthenticated for protected routes, which fetchJson handles as described below.

401 handling — redirect to login

fetchJson detects a 401 response and, if a token was present and the code is running in the browser, calls authStore.logout() to clear all stored user state and then hard-redirects to /login (core.ts:144-146):

if (response.status === 401 && token && typeof window !== 'undefined') { useAuthStore.getState().logout(); window.location.replace('/login'); }

There is no silent token refresh. Expiry results in a logout and redirect. This keeps the auth path simple at the cost of requiring re-login after session expiry.

💡 Tip: authStore.logout() sets the store to defaultUser (all fields null/false). Because the store is persisted, this also clears the "user" key in localStorage, signing the user out of the Svelte widget if it is running in the same browser.


Audit headers

When headers are sent

Audit headers are injected only on mutating methods. isMutatingMethod (core.ts:87-89) gates the logic:

function isMutatingMethod(method?: string) { return ['POST', 'PUT', 'PATCH', 'DELETE'].includes((method ?? 'GET').toUpperCase()); }

GET and HEAD requests receive no audit headers.

Header set

buildAuditHeaders (core.ts:91-111) adds up to four headers on every mutation:

HeaderAlways sent?SourceExample value
X-Audit-Request-IdYesoptions.auditRequestId or crypto.randomUUID()"3f2504e0-4f89-11d3-9a0c-0305e82c3301"
X-Audit-ActionOnly if auditAction setCaller-supplied string"finders.update"
X-Audit-Resource-TypeOnly if auditResourceType setCaller-supplied string"finder"
X-Audit-Resource-IdOnly if auditResourceId setCaller-supplied id coerced to string"42"

X-Audit-Request-Id is generated at the call site via crypto.randomUUID() if the caller does not supply one. This means each mutation gets a unique correlation ID with no manual wiring.

How callers set them

Audit fields are passed as properties of AuditRequestOptions alongside the standard method/body. A typical mutation in a resource module:

// src/lib/api/finders.ts:29-36 export const updateFinder = (id: number, data: Partial<FinderPayload>) => fetchAPI<FinderApiResource>(`finders/${id}`, { method: 'PUT', auditAction: 'finders.update', auditResourceType: 'finder', auditResourceId: id, body: JSON.stringify(toFinderPayload(data as FinderPayload, false)), }).then(normalizeFinder);

The convention is <resource>.<verb> for auditAction (e.g. locations.create, workspaces.delete, billing.create_checkout).

Backend processing — RecordAuditLog middleware

The audit middleware alias maps to App\Http\Middleware\RecordAuditLog (bootstrap/app.php:28). It is applied to all three protected route groups in routes/api.php:34,53,58,220.

RecordAuditLog::handle (RecordAuditLog.php:21-41) wraps the controller call in a try/catch/finally:

  1. Before the controller runs, it generates an internal $requestId UUID and stashes it on $request->attributes (RecordAuditLog.php:26-27). This is the server-side ID — the client-supplied X-Audit-Request-Id is read separately (see below).
  2. The controller runs normally. Exceptions are re-thrown after audit persistence.
  3. In finally, persistAuditLog creates an AuditLog model row (RecordAuditLog.php:48-86).

persistAuditLog builds the row as follows:

ColumnSource
user_id$request->user()->id (JWT guard — auth:api)
workspace_idAuditLogSanitizer::inferWorkspaceId() — reads workspace_id query param first, then falls back to route model binding
actionX-Audit-Action header if present; otherwise "METHOD /route/uri"
resource_typeAuditLogSanitizer::inferResourceType() — inspects bound route model instances
resource_idAuditLogSanitizer::inferResourceId() — first route parameter with an id property
request_idServer-generated UUID from step 1 above (not the client-supplied header)
methodHTTP method
route$request->route()->uri()
ip_address$request->ip()
request_dataSanitized request body (passwords, tokens redacted; file uploads summarized)
response_dataSanitized response body (AI endpoints truncated; invite endpoints filtered)
status_codeResponse HTTP status, or exception code, or 500
status"failure" if status ≥ 400, else "success"
duration_msWall time of controller execution
error_messageExtracted from response/exception body for failures

AuditLogSanitizer (app/Services/AuditLogSanitizer.php) handles workspace inference by checking the workspace_id query parameter first (the value the frontend sends), then walking route model binding parameters to find a model with a workspace_id or workspaces() relation. This means even endpoints that do not accept workspace_id explicitly will have the correct workspace_id recorded if a workspace-scoped model is in the route.

⚠️ Warning: The request_id stored in AuditLog is a server-generated UUID, not the X-Audit-Request-Id the client sends. The client header feeds the action/resource_type/resource_id columns only. If you need to correlate a client-side log entry with an audit row, use the action string and timestamp, not request_id.


Workspace scoping

buildWorkspaceAwareEndpoint

core.ts:24-35 exports a helper that appends workspace_id to a URL:

export function buildWorkspaceAwareEndpoint(endpoint: string, workspaceId?: number | null) { const resolvedWorkspaceId = workspaceId === undefined ? useWorkspaceStore.getState().activeWorkspaceId : workspaceId; if (!resolvedWorkspaceId) { return endpoint; } const separator = endpoint.includes('?') ? '&' : '?'; return `${endpoint}${separator}workspace_id=${resolvedWorkspaceId}`; }

When called with no workspaceId argument (the common case), it reads workspaceStore.activeWorkspaceId synchronously via .getState(). If the workspace store is empty (no active workspace), the endpoint is returned unchanged and the backend scopes the query to the authenticated user’s accessible resources.

Which requests include workspace_id

workspace_id is sent as a query parameter on collection fetches and as a form field on multipart uploads. It is not sent on single-resource fetches or mutations that already have the resource ID in the path.

Collection fetches (all use buildWorkspaceAwareEndpoint):

// finders.ts:15-16 export const getFinders = (workspaceId?: number | null) => fetchAPI<FinderApiResource[]>(buildWorkspaceAwareEndpoint('finders', workspaceId)) // locations.ts:11-13 export async function getLocations(workspaceId?: number | null) { const response = await fetchAPI<ApiCollectionResponse<LocationApiResource>>( buildWorkspaceAwareEndpoint('locations', workspaceId) );

Multipart uploads append it as a form field because query params and FormData bodies cannot be reliably combined with Content-Type: multipart/form-data:

// locations.ts:44-55 (image upload) const workspaceId = useWorkspaceStore.getState().activeWorkspaceId; formData.append('image', file); if (workspaceId) { formData.append('workspace_id', String(workspaceId)); }

Single-resource mutations (updateFinder, deleteLocation, etc.) do not include workspace_id — the resource ID in the path is sufficient for authorization and audit inference.


Error handling

fetchJson error path

When response.ok is false, fetchJson (core.ts:143-149):

  1. Checks for 401 + token → logout + redirect (see Authentication above).
  2. Calls getErrorMessage(response, fallback) which attempts to response.json() and extracts a human-readable string from the body.
  3. Throws new Error(message). TanStack Query catches this and puts it in mutation.error / query.error.

Error body parsing — getErrorMessageFromData

core.ts:37-70 exports getErrorMessageFromData, which walks the response JSON in priority order:

  1. data.message (string, non-empty)
  2. data.error (string, non-empty)
  3. data.errors (Laravel validation bag) — takes the first string in the first array value
  4. Any other top-level key whose value is a non-empty string array — takes the first entry

If none match, the HTTP status text is used as a fallback. This covers both Laravel’s standard validation responses ({ errors: { field: ["message"] } }) and custom error shapes.

In development mode only, the raw error data is logged to console.error (core.ts:80-82).

Surfacing errors to the user

Per AGENTS.md, all user-facing errors in the dashboard are surfaced as alerts via useUiStore.addAlert:

// src/stores/uiStore.ts addAlert: (message, type = 'success', duration = 4000) => { ... }

addAlert pushes an Alert object onto uiStore.alerts. The alert auto-removes after duration ms. Components call it in mutation onError callbacks:

// finders page.tsx:313-314 onError: () => addAlert('Failed to delete finder.', 'error'),

The 401-redirect path bypasses toast surfacing entirely — the page navigates away.

204 No Content

fetchJson returns an empty object {} as T for 204 responses (core.ts:151-153). Resource modules that call DELETE endpoints type their return as void and discard the value.


TanStack Query integration

Reads — useQuery

Domain fetch functions (e.g. getFinders, getLocations) are plain async functions that return a Promise. They are passed directly to queryFn:

// finders/page.tsx:290-293 const { data: finders = [], isLoading } = useQuery({ queryKey: ['finders', activeWorkspaceId], queryFn: () => getFinders(), });

queryKey always includes activeWorkspaceId for collection queries so that switching workspaces triggers a fresh fetch. Single-resource queries key on the resource id:

queryKey: ['finder', id]

Writes — useMutation

All write operations use useMutation. The mutationFn wraps the domain function directly:

// finders/page.tsx:308-315 const deleteMutation = useMutation({ mutationFn: deleteFinder, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['finders'] }); addAlert('Finder deleted.', 'success'); }, onError: () => addAlert('Failed to delete finder.', 'error'), });

Per AGENTS.md, always invalidate the relevant query keys in onSuccess — do not leave stale cache after a write. For mutations that affect a collection, invalidate both the collection key and any per-item key:

// AGENTS.md pattern onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['finder', id] }); queryClient.invalidateQueries({ queryKey: ['finders'] }); },

Resource modules as the API layer

All fetchAPI/fetchExt calls live in domain modules under src/lib/api/:

auth.ts finders.ts sets.ts customFields.ts admin.ts locations.ts tags.ts aiImports.ts core.ts maps.ts processes.ts aiImprovements.ts normalizers.ts payloads.ts workspaces.ts billing.ts

Raw wire types (…ApiResource) are defined alongside each module’s normalizers in normalizers.ts. payloads.ts holds the inverse transforms (domain → wire for writes). Components import from src/lib/api/index.ts which re-exports everything; they never reach into core.ts directly.

💡 Tip: To add a new API call: (1) add the function to the appropriate domain module, (2) if it returns a new resource shape, add a normalizer in normalizers.ts and a payload transformer in payloads.ts, (3) call it in a useQuery or useMutation in the component, and (4) add audit fields (auditAction, auditResourceType, auditResourceId) for any mutating method.


Request lifecycle summary

Component └─ useMutation / useQuery └─ domain function (finders.ts / locations.ts / …) └─ buildWorkspaceAwareEndpoint (GET collections only) └─ fetchAPI(endpoint, AuditRequestOptions) └─ fetchJson(API_BASE, endpoint, options) ├─ getToken() → authStore.getState().token ├─ headers: Authorization, Content-Type ├─ buildAuditHeaders() → X-Audit-* (mutations only) └─ fetch(API_BASE + endpoint, requestInit) ├─ 401 → logout() + window.location.replace('/login') ├─ 204 → return {} as T ├─ !ok → throw Error(parsed message) └─ ok → response.json() → T └─ normalizer (…ApiResource → domain type)