Development guide¶
How to add features to KCMS without breaking existing patterns.
Local dev setup¶
# In kavel-cms repo root:
pnpm install
pnpm --filter cms-admin dev
KCMS expects a running backend at BACKEND_API_URL (in
apps/cms-admin/.env.local). Three options:
- Use the deployed dev backend - easiest:
BACKEND_API_URL=http://204.168.180.77:3001 - SSH-tunnel to the deployed dev backend:
ssh -L 3001:rmsnordic-backend-dev:3001 rmsnordic-dev BACKEND_API_URL=http://localhost:3001 - Run a backend locally - see rmsnordic-backend README.
Adding a new admin area¶
Pattern: each major resource has a list view + detail view + a server proxy route. Bundles is a good template - duplicate its files and rename.
- Sidebar entry. In
components/Sidebar.tsx:
{ kind: "leaf", href: "/things", label: "Things",
icon: Box, perm: "things:read" },
-
List page:
app/(auth)/things/page.tsx(server component). Fetch from/api/admin/thingsand render a<DataTable>. -
Detail page:
app/(auth)/things/[id]/page.tsx. Fetch the single entity, render an edit form. -
New-record page:
app/(auth)/things/new/page.tsx→<ThingForm>that POSTs and redirects. -
API proxy:
app/api/admin/things/route.tsforGET(list) +POST(create).app/api/admin/things/[id]/route.tsforGET/PUT/DELETE. Use theadminFetchhelper fromlib/api.ts. -
Permission: register
things:read/things:editin the backend's role permissions and grant them to the Administrator role (or whichever role should access). The backend'sRolesControllerexposes the permission catalog; new perms appear automatically once they're referenced by an endpoint's@PreAuthorize. -
Test on dev, then deploy.
Conventions¶
File layout¶
- One folder per route segment.
- Server components are the default. Mark client components with
"use client"at the top. - Forms are client components (
use client) wrapped by server-component pages that fetch the initial data. - Naming:
<Resource>Form.tsxfor the form,<Resource>Editor.tsxfor the full edit screen,<Resource>Client.tsxfor client-only pages.
Naming¶
- Routes match resource names plural:
/products,/customers,/membership-tiers,/api-tokens(kebab-case). - Backend permissions follow
<resource>:<action>-products:read,orders:edit,roles:manage. - Page titles in Title Case ("Products", "Membership tiers").
Server actions vs API routes¶
Use a server action when:
- It's invoked from a <form action={...}>.
- After mutation, the page is revalidated and re-rendered.
- The action is short (one POST + redirect).
Use an API route when: - A client component (e.g. file upload, typeahead) needs JSON back. - The endpoint is consumed by something other than KCMS's own forms (rare but possible). - The mutation is part of a long-running UI flow with intermediate states.
Fetching from the backend¶
apps/cms-admin/lib/api.ts exposes adminFetch - the only function
that should touch BACKEND_API_URL directly. It:
- Reads the admin token from the cookie.
- Sets
Authorization: Bearer .... - Forwards
X-Forwarded-Forfor audit log purposes. - Returns
{ ok, status, data }.
Don't fetch(...) to the backend from anywhere else.
Error handling¶
API proxies should pass through the backend's status codes. If the backend says 404, return 404. If 403, return 403. The page that called the proxy decides whether to redirect, show an error toast, or render a fallback.
Permissions in the UI¶
The sidebar already filters by perm. For finer-grained UI control (e.g. show a "Delete" button only to admins):
import { hasPerm } from "@/types/admin";
import { useAdminMe } from "@/lib/admin-me";
export function DeleteButton({ id }: { id: string }) {
const me = useAdminMe();
if (!hasPerm(me, "things:edit")) return null;
return <button onClick={() => delete(id)}>Delete</button>;
}
For server-side enforcement, the backend handles it - the user can't DELETE via curl without the perm either.
Forms with react-hook-form + Zod¶
Conventional pattern:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
title: z.string().min(1),
priceMinor: z.coerce.number().int().min(0),
});
export function ProductForm({ initial }: { initial?: z.infer<typeof Schema> }) {
const { register, handleSubmit, formState: { errors } } =
useForm({ resolver: zodResolver(Schema), defaultValues: initial });
return (
<form onSubmit={handleSubmit(async (values) => {
const res = await fetch("/api/admin/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.ok) router.push("/products");
else setError(await res.text());
})}>
<input {...register("title")} />
{errors.title && <p>{errors.title.message}</p>}
...
</form>
);
}
For server actions, use the useTransition hook to disable buttons
during submit.
Adding a new permission¶
When you add a new resource that needs gating:
- Pick the permission strings (
<resource>:read,<resource>:edit). - In the backend's
SecurityConfig, add path matchers that require them. - In the backend's seeded
Role(Administrator), grant the new perms. - Restart the backend - it logs all known permissions at boot.
- In KCMS Settings → Roles, the new permissions appear in the matrix automatically. Tick them in the appropriate roles.
Coding style¶
- TypeScript strict mode. Don't use
any. If you must, comment why. - Async/await. No
.then()chains. - No
console.login committed code. Useconsole.warnfor legit warnings. - Tailwind for styling. No CSS modules. Brand colors are in
app/globals.cssas CSS vars. - One component per file, named export matching filename.
- Comments explain WHY, not WHAT. The code shows what.
Testing¶
We don't have an integration test suite (the storefront repo is the same - small team, real-world smoke tests via the dev environment).
The TypeScript compiler is the main safety net:
pnpm --filter cms-admin typecheck
Run this before every PR / push.
Common patterns¶
Confirming destructive actions¶
if (!confirm("Delete this? It cannot be undone.")) return;
Native confirm() is fine for admin tooling. We don't ship custom
modal dialogs for every confirmation - the audience is internal.
Loading states¶
For mutation forms, use useTransition:
const [pending, startTransition] = useTransition();
// ...
<button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
For server-rendered pages, the loading state is the next.js loading
boundary (Suspense). Add loading.tsx next to page.tsx for a
custom skeleton.
Empty states¶
Always render something for an empty list. Don't show a blank table. Example: "Inga produkter ännu. Lägg till den första →".
Pagination¶
Default to server-paginated tables (TanStack Table). Don't load 1000+ rows client-side.