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 todefaultUser(all fields null/false). Because the store is persisted, this also clears the"user"key inlocalStorage, 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:
| Header | Always sent? | Source | Example value |
|---|---|---|---|
X-Audit-Request-Id | Yes | options.auditRequestId or crypto.randomUUID() | "3f2504e0-4f89-11d3-9a0c-0305e82c3301" |
X-Audit-Action | Only if auditAction set | Caller-supplied string | "finders.update" |
X-Audit-Resource-Type | Only if auditResourceType set | Caller-supplied string | "finder" |
X-Audit-Resource-Id | Only if auditResourceId set | Caller-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:
- Before the controller runs, it generates an internal
$requestIdUUID and stashes it on$request->attributes(RecordAuditLog.php:26-27). This is the server-side ID — the client-suppliedX-Audit-Request-Idis read separately (see below). - The controller runs normally. Exceptions are re-thrown after audit persistence.
- In
finally,persistAuditLogcreates anAuditLogmodel row (RecordAuditLog.php:48-86).
persistAuditLog builds the row as follows:
| Column | Source |
|---|---|
user_id | $request->user()->id (JWT guard — auth:api) |
workspace_id | AuditLogSanitizer::inferWorkspaceId() — reads workspace_id query param first, then falls back to route model binding |
action | X-Audit-Action header if present; otherwise "METHOD /route/uri" |
resource_type | AuditLogSanitizer::inferResourceType() — inspects bound route model instances |
resource_id | AuditLogSanitizer::inferResourceId() — first route parameter with an id property |
request_id | Server-generated UUID from step 1 above (not the client-supplied header) |
method | HTTP method |
route | $request->route()->uri() |
ip_address | $request->ip() |
request_data | Sanitized request body (passwords, tokens redacted; file uploads summarized) |
response_data | Sanitized response body (AI endpoints truncated; invite endpoints filtered) |
status_code | Response HTTP status, or exception code, or 500 |
status | "failure" if status ≥ 400, else "success" |
duration_ms | Wall time of controller execution |
error_message | Extracted 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_idstored inAuditLogis a server-generated UUID, not theX-Audit-Request-Idthe client sends. The client header feeds theaction/resource_type/resource_idcolumns only. If you need to correlate a client-side log entry with an audit row, use theactionstring and timestamp, notrequest_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):
- Checks for 401 + token → logout + redirect (see Authentication above).
- Calls
getErrorMessage(response, fallback)which attempts toresponse.json()and extracts a human-readable string from the body. - Throws
new Error(message). TanStack Query catches this and puts it inmutation.error/query.error.
Error body parsing — getErrorMessageFromData
core.ts:37-70 exports getErrorMessageFromData, which walks the response JSON in priority order:
data.message(string, non-empty)data.error(string, non-empty)data.errors(Laravel validation bag) — takes the first string in the first array value- 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.tsRaw 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.tsand a payload transformer inpayloads.ts, (3) call it in auseQueryoruseMutationin 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)Related pages
- ./index.md — integration points registry and anti-pattern list
- ../../architecture/auth.md — JWT issuance, Sanctum token lifecycle
- ../../codebases/nextjs/state-management.md —
authStore,workspaceStore,uiStorein full - ../../codebases/backend/routing.md —
auth:api,audit,auth_sessionmiddleware stack;/ext/route group