Frontend Development Workflow
The frontend is a React/TypeScript/Vite client for the REST API. It is designed to be easy to replace without changing the backend's domain ownership.
Runtime responsibilities
The frontend owns:
- routes and navigation;
- layout, typography, responsive behavior, and Material UI composition;
- form state and user feedback;
- server-state fetching and cache invalidation through TanStack Query;
- attaching JWTs and handling one access-token refresh through Axios;
- presenting API validation and permission errors.
The frontend does not own:
- organization membership or role decisions;
- tenant selection during writes;
- CRM validation that must also protect non-browser clients;
- automation execution;
- persistence of access or refresh tokens.
Request rule: hooks at the React boundary
Requests used by React should be exposed as hooks. This is a good practice here because it gives every page the same cache, loading, error, cancellation, and authentication behavior. It also keeps URLs and response types out of visual components.
The layers are intentionally separate:
page/component → feature hook → feature service → services/api.ts → Axiosservices/api.ts is the low-level transport: it knows Axios, Bearer headers,
refresh, and API errors. A feature's services/ folder owns its raw endpoint
calls. Its hooks/ folder owns query keys, TanStack Query definitions, and React
hooks. Shared domain interfaces live in types/.
For example:
const companies = useCompanies(organizationId)A page should not contain request('/organizations/...'), create an Axios
instance, or manually manage isLoading and cache invalidation for a server
request. Login/logout remain an explicit exception because they are auth
orchestration actions; they live in features/auth/hooks/useAuth.tsx and use the
auth service.
See Frontend Rules for the enforceable checklist.
Directory map
frontend/src/
├── components/
│ ├── common/ reusable dialogs and feedback states
│ └── layout/ authenticated application shell
├── config/ TanStack Query client and app configuration
├── context/ shared React contexts
├── features/
│ ├── auth/ hooks, services, Zustand store
│ ├── organizations/ domain hooks and services
│ ├── companies/ domain hooks and services
│ ├── contacts/ domain hooks and services
│ ├── automations/ domain hooks and services
│ ├── invitations/ invitation hooks and services
│ └── organizations/ workspace, settings, and email account hooks/services
├── hooks/ globally reusable hooks
├── pages/ route-level screens grouped by page
├── routes/ TanStack Router configuration
├── services/ Axios client and shared external services
├── styles/ Material UI theme
└── types/ shared TypeScript domain interfacesHow authentication works
Login is a two-request flow:
POST /api/v1/auth/token/with username and password.- Store access and refresh tokens in Zustand with same-tab
sessionStoragepersistence. GET /api/v1/auth/me/with the access token.- Store the returned user and organizations in the auth context/state.
Axios adds the access token to normal API requests. On a 401 it makes one
POST /api/v1/auth/token/refresh/ request. Concurrent failed requests share
one refresh promise, which prevents a burst of expired requests from rotating
the refresh token multiple times. A successful rotation replaces both tokens;
failure clears the session.
Logout posts the refresh token to /api/v1/auth/logout/, then clears Zustand and
the TanStack Query cache even if the server call fails.
Tokens persist only for the current browser tab through the Zustand store. A future HttpOnly-cookie design must be an explicit security decision and documented as an API/browser contract change.
Theme and brand customization
The application has one Material UI ThemeProvider in main.tsx. Developers
customize the small set of intentional inputs in src/styles/brand.ts:
- primary and secondary brand colors;
- application background and surface;
- primary and muted text;
- font family;
- base border radius.
src/styles/theme.ts derives light/dark primary variants, dividers, action
states, table styles, and shared component defaults. Pages and components use
semantic palette paths instead of color literals. This lets a self-hosting team
replace the base palette without searching through the application.
The detailed contract and agent rules are in
src/styles/README.md.
Adding a read-only page
- Add or update the response type in
src/types/. - Add the raw endpoint call in the feature's
services/folder. - Add the query key,
queryOptions, and hook in the feature'shooks/folder. - Include the organization ID in both the URL and query key for tenant data.
- Add a named
*Page.tsxundersrc/pages/<PageName>/. - Add its route in
src/routes/router.tsxand navigation entry in the layout. - Handle loading, empty, error, and successful states.
- Add a browser test with a mocked API response.
Example query shape:
export const companyKeys = {
byOrganization: (organizationId: string) =>
['organizations', organizationId, 'companies'] as const,
}
const companiesQuery = (organizationId: string) => queryOptions({
queryKey: companyKeys.byOrganization(organizationId),
queryFn: ({ signal }) => companyService.list(organizationId, signal),
})
export const useCompanies = (organizationId: string) =>
useQuery(companiesQuery(organizationId))The query definition may remain private. Export the hook and key factory through
the feature's index.ts. Route pages are imported directly by the router so
lazy loading is not defeated by a feature barrel imported elsewhere.
Adding a create form
Use the shared CreateDialog when the form is a simple POST:
- Render the form only when the current capability allows it.
- Treat that visibility as UX, not security.
- Send writable fields only; never send an organization ID supplied by a workspace selector.
- Display field-level API validation errors.
- Invalidate the affected query after a 2xx response.
- Test both success and a representative 400 response.
The backend remains authoritative when a viewer calls the endpoint directly.
Local commands
From frontend/:
npm ci
npm run dev
npm run lint
npm test
npm run build
npm run test:e2eThe browser suite mocks /api/v1/ traffic. It verifies UI behavior and tenant
separation without requiring a running Django server.
TanStack Router conventions
Routes are defined in src/routes/router.tsx with TanStack Router's code-based
route tree. The root route owns the shell, the organization route owns the
$organizationId parameter, and child routes own overview, companies,
contacts, and automations screens.
Use route templates and params for dynamic navigation:
<Link
to="/organizations/$organizationId/companies"
params={{ organizationId: organization.id }}
>
Companies
</Link>This is preferable to concatenating /organizations/ strings because the
router can type-check the route and its required parameters. When a component
library's polymorphic component={Link} type cannot express TanStack params,
wrap the visual component in a typed TanStack Link and keep the visual child
non-interactive.
Workspace routes use beforeLoad to redirect unauthenticated sessions to the
public root route. The backend still performs the real authorization check when
the workspace data is fetched; the router guard is only a navigation and UX
boundary.
Django Workflow
This guide explains the normal flow when adding something to the Django project.
Database Setup
Bubllio CRM uses Django's ORM and database backends. Application code does not contain custom database adapters. Developers select a supported backend through one environment variable: DATABASEURL.
