Frontend project setup — deep walkthrough#
tutorial/frontend/01-setup.md covers the three-command start. This page is the fuller version — the files you actually need, how they fit together, and what to configure before you build your first grid.
1. Stack#
| Concern | Choice |
|---|---|
| Build | Vite 7+ with @vitejs/plugin-react |
| Language | TypeScript 5.9+ |
| Runtime | React 19 + react-dom 19 |
| UI kit | @mantine/core, @mantine/dates, @mantine/hooks (v8) — or the MUI variants |
| Data layer | @palmyralabs/palmyra-wire — store factory (single source of network truth) |
| Forms | @palmyralabs/rt-forms + @palmyralabs/rt-forms-mantine (or -mui) |
| Grid / form templates | @palmyralabs/template-tribble — SummaryGrid, DialogNewForm, DialogEditForm, DynamicMenu |
| Router | react-router-dom v7 |
| Notifications | react-toastify |
| HTTP | axios (for interceptors); palmyra-wire uses it under the hood |
| Utilities | @palmyralabs/ts-utils, dayjs |
2. package.json — minimum deps#
{
"name": "my-web",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint ."
},
"dependencies": {
"@mantine/core": "^8.3.1",
"@mantine/dates": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@palmyralabs/palmyra-wire": "^1.2.0",
"@palmyralabs/rt-forms": "github:palmyralabs/rt-forms",
"@palmyralabs/rt-forms-mantine": "github:palmyralabs/rt-forms-mantine",
"@palmyralabs/template-tribble": "github:palmyralabs/rt-template-tribble",
"@palmyralabs/ts-utils": "^0.3.0",
"axios": "^1.12.2",
"dayjs": "^1.11.18",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"react-router-dom": "^7.9.1",
"react-toastify": "^11.0.5"
},
"devDependencies": {
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react":"^5.0.3",
"typescript": "~5.9.2",
"vite": "^7.1.6",
"vite-tsconfig-paths": "^5.1.4"
}
}3. vite.config.ts#
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
server: {
port: 3000,
open: '/app/login',
proxy: {
'/api': {
target: 'http://localhost:6060',
changeOrigin: false,
secure: false,
},
},
},
});The proxy is critical — forwards /api/** to the Spring Boot service so withCredentials cookies stay same-origin from the browser’s perspective. Without this, CSRF / session won’t round-trip in dev.
4. tsconfig.json — path-alias for clean imports#
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"baseUrl": "./src",
"paths": {
"admin/*": ["admin/*"],
"common/*": ["common/*"],
"components/*": ["components/*"],
"config/*": ["config/*"],
"pages/*": ["pages/*"],
"wire/*": ["wire/*"]
}
},
"include": ["src"]
}Combined with vite-tsconfig-paths, every import resolves from src/ — no more ../../../config/ServiceEndpoints.
5. src/ layout#
src/
├── main.tsx entry
├── App.tsx routes
├── index.css shared base + theme import
├── themes/Colors.css CSS variables
├── config/
│ ├── ServiceEndpoints.ts single source of backend URLs
│ └── aclCode/AclPermissions.ts logical ACL class × code map
├── wire/
│ └── StoreFactory.ts PalmyraStoreFactory + useFormstore / useGridstore / useTreestore
├── common/
│ ├── layout/MainLayout.tsx authenticated shell (Sidebar + Topbar + Outlet)
│ ├── sidebar/Sidebar.tsx DynamicMenu host
│ ├── topbar/Topbar.tsx user label + logout + change-password
│ └── auth/RequireAuth.tsx /login gate
├── admin/
│ ├── components/dialog/ ChangePasswordDialog, ResetPasswordDialog
│ └── pages/userManagement/ user/ + group/ grid+new+edit+view
├── pages/
│ └── login/LoginPage.tsx
└── components/
└── acl/CheckAclAccess.tsx ACL-gated wrapper for buttons/icons6. StoreFactory.ts — the one place HTTP is wired#
import { IEndPoint, PalmyraStoreFactory } from '@palmyralabs/palmyra-wire';
import { toast } from 'react-toastify';
export const ACL_ACCESS_KEY = 'aclAccess';
const errorHandler = () => (error: any) => {
const s = error?.response?.status;
if (s === 401) {
localStorage.removeItem(ACL_ACCESS_KEY);
window.location.assign('/app/login');
return true;
}
if (s === 403) { toast.error("You don't have permission."); return true; }
if (s >= 500) { toast.error('Server error.'); return true; }
return false;
};
const AppStoreFactory = new PalmyraStoreFactory({
baseUrl: '/api/palmyra',
errorHandlerFactory: errorHandler,
storeOptions: {
axiosCustomizer: (ax) => {
ax.defaults.withCredentials = true; // JSESSIONID + CSRF cookies
// Axios defaults already match Spring's CSRF cookie / header names
},
},
});
export const useFormstore = (ep: IEndPoint, opts = {}, idKey?: string) =>
AppStoreFactory.getFormStore(opts, ep, idKey);
export const useGridstore = (ep: IEndPoint, opts = {}, idKey?: string) =>
AppStoreFactory.getGridStore(opts, ep, idKey);
export const useTreestore = (ep: IEndPoint, opts = {}) =>
AppStoreFactory.getTreeStore(opts, ep);
export default AppStoreFactory;See Session Auth wiring for the full recipe including CSRF, 401 handling, and axios customizer details.
7. App.tsx — routes + guard#
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { RequireAuth } from 'common/auth/RequireAuth';
import { MainLayout } from 'common/layout/MainLayout';
import { LoginPage } from 'pages/login/LoginPage';
export default function App() {
return (
<BrowserRouter basename="/app">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={<RequireAuth><MainLayout /></RequireAuth>}
>
<Route index element={<Navigate to="/patient" replace />} />
{/* feature pages go here */}
</Route>
</Routes>
</BrowserRouter>
);
}8. MainLayout + Sidebar + Topbar#
The standard authenticated shell. Sidebar renders the ACL-filtered menu via DynamicMenu (see Dynamic Navigation). Topbar fetches /user/about on mount to display the current user’s name — no localStorage.currentUser cache; the server is the source of truth.
// common/topbar/Topbar.tsx
const Topbar = () => {
const [user, setUser] = useState<{loginName?: string; displayName?: string}>({});
useEffect(() => {
AppStoreFactory.getFormStore({}, '/user/about', '').get({})
.then((r: any) => setUser(r?.result ?? r ?? {}))
.catch(() => setUser({}));
}, []);
return (
<div className="topbar">
<span>{user.displayName ?? user.loginName ?? ''}</span>
{/* change password button, logout button ... */}
</div>
);
};9. Per-feature grid — the shape#
import { SummaryGrid } from '@palmyralabs/template-tribble';
import { ColumnDefinition, useGridColumnCustomizer } from '@palmyralabs/rt-forms';
const columns: ColumnDefinition[] = [
{ attribute: 'patientCode', name: 'patientCode', label: 'Patient Code', type: 'string', searchable: true, sortable: true },
{ attribute: 'externalCode', name: 'externalCode', label: 'External', type: 'string', searchable: true },
{ attribute: 'gender', name: 'gender', label: 'Gender', type: 'string' },
];
export function PatientGridPage() {
const customizer = useGridColumnCustomizer({
gender: () => (info: any) =>
info.row.original.gender?.name ?? info.row.original.gender ?? '',
});
return (
<SummaryGrid
title="Patient"
pageName="patient"
columns={columns}
customizer={customizer}
pageSize={[15, 30, 45]}
options={{ endPoint: '/patient' }}
/>
);
}Always route cell renderers through useGridColumnCustomizer — hand-rolling the GridCustomizer object skips formatFooter and crashes at runtime.
10. Day-one gotchas#
- Vite proxy is non-negotiable — without it, cookies are cross-origin in dev and CSRF fails.
useGridColumnCustomizernot a custom object —{formatCell, formatHeader}withoutformatFooter→ runtimeformatFooter is not a function.- Nested FK cell renderers must dot-walk —
gendercomes back as{id, name}, not a scalar. Always write defensive renderers that handle both shapes. - Route paths must match
xpm_menu.code— or the DynamicMenu click goes to the wrong path. See Dynamic Navigation. @palmyralabs/template-tribbleSummaryGrid defaultclickTo = 'view'— row click navigates toview/{id}. SetdisableRowClickif you don’t have a view page yet.
See also#
- Session Auth wiring — the
StoreFactoryrecipe in full - Dynamic Navigation — wiring the data-driven sidebar
- Mantine grid reference