Frontend conventions
This is the coding standard for the Lockstep VM web app in webapp/frontend. It
is written for AI coding agents: follow the patterns here exactly — copy the
canonical skeletons, keep the same query-key and auth conventions, and do not
introduce new client libraries or a global provider. Related reading:
Frontend & design system (UI kit, DataTable,
QueryBuilder) and the API reference.
The stack, in one table
| Concern | Choice | Notes |
|---|---|---|
| Framework | Astro 4 (output: 'server') | SSR via @astrojs/node standalone adapter, port 4321 |
| Interactivity | React 18 islands | Hydrated with client:load from .astro pages |
| Data fetching | @tanstack/react-query v5 | One QueryClient per island — never a global provider |
| HTTP | axios (src/lib/api.ts) + fetch wrappers (src/lib/http.ts) | Both send the session cookie |
| Auth | Better Auth (httpOnly cookie on the Astro origin) | See AuthCheck, cookie flows via credentials: 'include' |
| Styling | Tailwind (applyBaseStyles: false) + lucide-react icons | Shared UI kit in src/components/ui |
| Charts | recharts | Used by dashboard widgets |
| TypeScript | astro/tsconfigs/strict, jsx: react-jsx | Path alias @/* → ./src/* |
| Package manager / build | Bun (bun install, bun run build) | Image is baked; see Build & deploy |
Project structure
webapp/frontend/├── astro.config.mjs # SSR + node adapter, react + tailwind integrations├── tsconfig.json # strict, @/* path alias├── Dockerfile # bun builder → node runtime (baked image)└── src/ ├── pages/ # .astro routes (one file = one URL) ├── layouts/ # Layout.astro / LayoutV2.astro (html shell) ├── components/ # React islands (one file per screen) │ ├── ui/ # shared design-system kit, barrel-exported (index.ts) │ └── dashboard/ # dashboard-specific composition ├── widgets/ # dashboard widget registry (index.tsx + types.ts) ├── lib/ │ ├── api.ts # axios client + typed API modules (inventoryApi, …) │ ├── http.ts # fetch wrappers: apiFetch / apiJson / ApiError │ ├── auth.ts # Better Auth server config (SSR only) │ ├── auth-client.ts # Better Auth browser client │ └── utils.ts # cn() + misc helpers └── styles/ # tokens.css, globals.cssRules
- One React island = one file in
src/components/namedPascalCase.tsx, exporting aPascalCasefunction of the same name (plusdefault). - Screens named
<Thing>Pageare full-screen islands (e.g.Rapid7Page,ReportsPage). Sub-views/detail panes drop the suffix (DeviceDetail). - Import shared UI from the barrel:
import { Card, Button } from '@/components/ui'. - Always import with the
@/alias, never deep relative paths (../../lib/...).
The Astro island model
.astro pages render on the server and hydrate React islands with
client:load. The canonical page nests three islands: an auth guard, the app
chrome, then the screen.
---import Layout from '../layouts/Layout.astro';import { AuthCheck } from '../components/AuthCheck';import { AppLayout } from '../components/AppLayout';import { Rapid7Page } from '../components/Rapid7Page';---
<Layout title="Rapid7 InsightVM - Vulnerability Management"> <AuthCheck client:load> <AppLayout client:load> <Rapid7Page client:load /> </AppLayout> </AuthCheck></Layout>AuthCheck(src/components/AuthCheck.tsx) is a client-side route guard. It hits/api/auth/get-sessiononce on mount and redirects to/loginif there is no session. It uses a plainuseEffect(notauthClient.useSession()) so SSR and first client render are identical — this avoids React #418/#423 hydration mismatches. Do not replace it with a hook-based session check.AppLayoutis a thin alias forAppShell(sidebar, top bar, theme toggle). Wrap every authenticated screen in it.- Use
client:loadfor these islands (they are interactive immediately). Only reach forclient:visible/client:idleif a heavy island is genuinely below the fold — the established convention isclient:load.
Per-island QueryClient pattern (canonical)
Each island creates its own QueryClient at module scope and wraps its
content in a QueryClientProvider. There is deliberately no app-wide
provider — islands are independent React trees, so a shared client cannot span
them. This is the single most important frontend convention.
Default options are always the same:
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },});The exported component is a thin provider wrapper around a …Content
component that holds all the hooks and JSX (verified in Rapid7Page.tsx,
Dashboard.tsx):
export function Rapid7Page() { return ( <QueryClientProvider client={queryClient}> <Rapid7Content /> </QueryClientProvider> );}useQueryClient() (aliased qc) is used inside …Content for
invalidation. Never call queryClient.invalidateQueries on the module-scope
instance directly from event handlers — go through useQueryClient().
The API client
Two clients exist; both send the Better Auth session cookie cross-origin via
credentials. Auth is cookie-only: Better Auth issues an httpOnly cookie on the
Astro origin (localhost:4321), and the FastAPI backend’s get_current_user
dependency validates it. Never attach Authorization / Bearer headers or
read tokens from localStorage — that path is removed.
Base URL resolution (identical in both clients):
const API_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000';// requests target `${API_URL}/api/v1` → http://localhost:8000/api/v1 in dev1. src/lib/api.ts — axios + typed API modules (preferred for CRUD)
api.ts exports a single configured axios instance and a set of typed API
modules grouping endpoints by domain. Prefer these functions as react-query
queryFns — they return res.data already unwrapped.
export const api = axios.create({ baseURL: `${API_URL}/api/v1`, headers: { 'Content-Type': 'application/json' }, withCredentials: true, // sends the session cookie on every call});A response interceptor handles auth failures globally: on 401 it clears
any stale access_token and redirects to /login — unless the user is already
on an auth page (/login, /signup, /forgot-password, /reset-password),
to avoid redirect loops during sign-up / password reset.
Exported modules (each method returns unwrapped .data):
| Module | Example methods |
|---|---|
authApi | getCurrentUser(), signOut() |
inventoryApi | uploadFile(file), listApplications(params), searchApplications(body), getStats() |
devicesApi | listDevices(params), searchDevices(body), getDeviceById(id), getStats() |
vulnerabilityApi | scan(...), listScans(params), getScan(id), listVulnerabilities(params), getStats() |
researchApi | searchCVE(id), searchProduct(product, vendor?), getHistory(params) |
reportApi | generate(data), list(params), get(id), download(id) (blob), delete(id) |
versionApi | getSummary(), getCompliance(), getOutdated(minVersions?) |
Usage in a query:
import { inventoryApi } from '@/lib/api';
const { data, isLoading } = useQuery({ queryKey: ['inventory-stats'], queryFn: inventoryApi.getStats, // returns res.data});For file uploads, set multipart/form-data and pass FormData (see
inventoryApi.uploadFile). For binary downloads use responseType: 'blob'
(see reportApi.download).
2. src/lib/http.ts — fetch wrappers (apiFetch / apiJson)
For endpoints without an api.ts module — commonly the integration screens —
use the fetch wrappers. apiJson<T> prefixes /api/v1, sends
credentials: 'include', sets JSON headers (skipped for FormData), redirects
on 401, and throws a typed ApiError (with status + body) on non-2xx:
import { apiJson, ApiError } from '@/lib/http';
const cfg = await apiJson<{ configured: boolean }>('/rapid7/config');await apiJson('/rapid7/config', { method: 'POST', body: JSON.stringify(payload) });Legacy inline fetch (recognize, don’t extend)
Older integration screens (Rapid7Page, Dashboard) still define a local
helper. When editing these, keep the existing style but always retain
credentials: 'include'; for new code prefer apiJson:
const API = 'http://localhost:8000/api/v1';async function apiFetch(path: string, opts: RequestInit = {}) { const res = await fetch(`${API}${path}`, { credentials: 'include', ...opts, headers: { 'Content-Type': 'application/json' } }); if (!res.ok) throw new Error(await res.text()); return res.json();}React Query conventions
Query keys
- Keys are arrays. First element is a stable string namespace; for
integration screens prefix it (e.g.
['r7-config'],['r7-stats']). - Every input that changes the result goes into the key so it stays cache- correct and refetches on change:
useQuery({ queryKey: ['r7-apps', searchApps, minDevices], queryFn: () => apiFetch(`/rapid7/apps?…`), enabled: view === 'apps', // gate expensive queries by active view});Invalidation (mutations)
Mutations use useMutation and invalidate the affected keys in onSuccess via
useQueryClient():
const qc = useQueryClient();const saveMutation = useMutation({ mutationFn: () => apiFetch('/rapid7/config', { method: 'POST', body: JSON.stringify(cfg) }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['r7-config'] }); qc.invalidateQueries({ queryKey: ['r7-stats'] }); }, onError: (e: any) => setMsg(`✗ ${e.message}`),});Polling (refetchInterval)
Long-running backend jobs (syncs, scans) are surfaced by conditional
polling: return a millisecond interval while a job is running, else false
to stop. This is the established pattern for every integration sync screen:
useQuery({ queryKey: ['r7-sync-status'], queryFn: () => apiFetch('/rapid7/sync-status'), refetchInterval: (data: any) => data?.sync_status === 'running' ? 2000 : false,});Use 2000–3000 ms; stop as soon as status leaves running. Do not set a global
refetchInterval default.
TypeScript & naming
tsconfig.jsonextendsastro/tsconfigs/strictwithjsx: react-jsx,jsxImportSource: react, and the@/*→./src/*path alias. Honor strict mode.- Naming: components/files
PascalCase.tsx; hooks/handlerscamelCase; the inner render component is<Name>Content; query-key namespaces kebab/prefixed strings. - Local
const API = …and helper types are fine at module scope. Backend response shapes are frequently typed asanyin existing screens — acceptable when the payload is dynamic, but prefer a real interface orapiJson<T>for new code. astro buildrunsastro checkfirst (bun run build=astro check && astro build), so type errors fail the build. Keep islands type-clean.
How to add …
… a React component / screen
- Create
src/components/MyThingPage.tsx. - Add a module-scope
queryClientwith the standard defaults. - Put hooks + JSX in
MyThingPageContent; export a provider wrapper. - Import UI from
@/components/ui, icons fromlucide-react.
… a page (route)
- Create
src/pages/my-thing.astro. - Nest
AuthCheck > AppLayout > <MyThingPage />, allclient:load. - Add the route to the docs sidebar / app nav if it is user-facing.
… an integration screen
Model it on Rapid7Page.tsx: a config query (['x-config']), a stats
query, a sync-status query with conditional refetchInterval, and
test-connection / config (POST/DELETE) / sync mutations that invalidate
the config + stats keys. Gate list queries with enabled: view === '…'.
Canonical component template (copy-paste)
import React, { useState } from 'react';import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient,} from '@tanstack/react-query';import { Card, CardHeader, CardTitle, CardContent, Button } from '@/components/ui';import { apiJson } from '@/lib/http';
// One QueryClient per island. Never a global/shared provider.const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },});
function MyThingContent() { const qc = useQueryClient(); const [view, setView] = useState<'overview' | 'config'>('overview');
// Read: key namespace first, then every input that changes the result. const { data: stats } = useQuery({ queryKey: ['mything-stats'], queryFn: () => apiJson('/mything/stats'), // Poll only while a job is running; stop otherwise. refetchInterval: (d: any) => d?.sync_status === 'running' ? 3000 : false, });
const { data: items } = useQuery({ queryKey: ['mything-items', view], queryFn: () => apiJson('/mything/items'), enabled: view === 'overview', });
// Write: invalidate the affected keys on success. const syncMutation = useMutation({ mutationFn: () => apiJson('/mything/sync', { method: 'POST' }), onSuccess: () => qc.invalidateQueries({ queryKey: ['mything-stats'] }), });
return ( <Card> <CardHeader><CardTitle>My Thing</CardTitle></CardHeader> <CardContent> <Button onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending}> Sync </Button> </CardContent> </Card> );}
export function MyThingPage() { return ( <QueryClientProvider client={queryClient}> <MyThingContent /> </QueryClientProvider> );}
export default MyThingPage;Build & deploy caveat
The frontend ships as a baked image, not a live-mounted directory.
-
Dockerfilebuilds with Bun (bun install, thenbun run build) and runs the compiled Astro server on Node:node ./dist/server/entry.mjs,HOST=0.0.0.0,PORT=4321. -
In
webapp/docker-compose.ymlthefrontendservice is built, not volume-mounted. Unlike thebackendservice (which bind-mounts./backendfor hot reload), the frontend has no source volume — a code change is not visible until you rebuild:Terminal window docker compose build frontend && docker compose up -d frontend -
PUBLIC_*env vars are inlined into the client bundle at build time (Viteimport.meta.env).PUBLIC_API_URLandPUBLIC_BETTER_AUTH_URLare baked duringbun run build. Changing them at runtime has no effect on the browser bundle — you must rebuild the image. Server-only secrets (BETTER_AUTH_SECRET,MONGODB_URL,RESEND_API_KEY) are read at runtime by the SSR process and are safe to change with a restart. -
Local dev bypasses all of this:
bun run dev(astro dev) on port 4321 with HMR and reads.env/shellPUBLIC_API_URLlive.
Takeaway for agents: after editing anything under src/, tell the operator
to docker compose build frontend (or run bun run dev); a plain
up -d/restart will silently serve the old bundle.