Skip to content

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

ConcernChoiceNotes
FrameworkAstro 4 (output: 'server')SSR via @astrojs/node standalone adapter, port 4321
InteractivityReact 18 islandsHydrated with client:load from .astro pages
Data fetching@tanstack/react-query v5One QueryClient per island — never a global provider
HTTPaxios (src/lib/api.ts) + fetch wrappers (src/lib/http.ts)Both send the session cookie
AuthBetter Auth (httpOnly cookie on the Astro origin)See AuthCheck, cookie flows via credentials: 'include'
StylingTailwind (applyBaseStyles: false) + lucide-react iconsShared UI kit in src/components/ui
ChartsrechartsUsed by dashboard widgets
TypeScriptastro/tsconfigs/strict, jsx: react-jsxPath alias @/*./src/*
Package manager / buildBun (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.css

Rules

  • One React island = one file in src/components/ named PascalCase.tsx, exporting a PascalCase function of the same name (plus default).
  • Screens named <Thing>Page are 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.

src/pages/rapid7.astro
---
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-session once on mount and redirects to /login if there is no session. It uses a plain useEffect (not authClient.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.
  • AppLayout is a thin alias for AppShell (sidebar, top bar, theme toggle). Wrap every authenticated screen in it.
  • Use client:load for these islands (they are interactive immediately). Only reach for client:visible/client:idle if a heavy island is genuinely below the fold — the established convention is client: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 dev

1. 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):

ModuleExample methods
authApigetCurrentUser(), signOut()
inventoryApiuploadFile(file), listApplications(params), searchApplications(body), getStats()
devicesApilistDevices(params), searchDevices(body), getDeviceById(id), getStats()
vulnerabilityApiscan(...), listScans(params), getScan(id), listVulnerabilities(params), getStats()
researchApisearchCVE(id), searchProduct(product, vendor?), getHistory(params)
reportApigenerate(data), list(params), get(id), download(id) (blob), delete(id)
versionApigetSummary(), 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.json extends astro/tsconfigs/strict with jsx: react-jsx, jsxImportSource: react, and the @/*./src/* path alias. Honor strict mode.
  • Naming: components/files PascalCase.tsx; hooks/handlers camelCase; 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 as any in existing screens — acceptable when the payload is dynamic, but prefer a real interface or apiJson<T> for new code.
  • astro build runs astro check first (bun run build = astro check && astro build), so type errors fail the build. Keep islands type-clean.

How to add …

… a React component / screen

  1. Create src/components/MyThingPage.tsx.
  2. Add a module-scope queryClient with the standard defaults.
  3. Put hooks + JSX in MyThingPageContent; export a provider wrapper.
  4. Import UI from @/components/ui, icons from lucide-react.

… a page (route)

  1. Create src/pages/my-thing.astro.
  2. Nest AuthCheck > AppLayout > <MyThingPage />, all client:load.
  3. 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.

  • Dockerfile builds with Bun (bun install, then bun 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.yml the frontend service is built, not volume-mounted. Unlike the backend service (which bind-mounts ./backend for 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 (Vite import.meta.env). PUBLIC_API_URL and PUBLIC_BETTER_AUTH_URL are baked during bun 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/shell PUBLIC_API_URL live.

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.