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-tribbleSummaryGrid, 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/icons

6. 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.
  • useGridColumnCustomizer not a custom object{formatCell, formatHeader} without formatFooter → runtime formatFooter is not a function.
  • Nested FK cell renderers must dot-walkgender comes 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-tribble SummaryGrid default clickTo = 'view' — row click navigates to view/{id}. Set disableRowClick if you don’t have a view page yet.

See also#