Skip to content

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:

  1. Use the deployed dev backend - easiest:
    BACKEND_API_URL=http://204.168.180.77:3001
    
  2. SSH-tunnel to the deployed dev backend:
    ssh -L 3001:rmsnordic-backend-dev:3001 rmsnordic-dev
    BACKEND_API_URL=http://localhost:3001
    
  3. 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.

  1. Sidebar entry. In components/Sidebar.tsx:
{ kind: "leaf", href: "/things", label: "Things",
  icon: Box, perm: "things:read" },
  1. List page: app/(auth)/things/page.tsx (server component). Fetch from /api/admin/things and render a <DataTable>.

  2. Detail page: app/(auth)/things/[id]/page.tsx. Fetch the single entity, render an edit form.

  3. New-record page: app/(auth)/things/new/page.tsx<ThingForm> that POSTs and redirects.

  4. API proxy: app/api/admin/things/route.ts for GET (list) + POST (create). app/api/admin/things/[id]/route.ts for GET / PUT / DELETE. Use the adminFetch helper from lib/api.ts.

  5. Permission: register things:read / things:edit in the backend's role permissions and grant them to the Administrator role (or whichever role should access). The backend's RolesController exposes the permission catalog; new perms appear automatically once they're referenced by an endpoint's @PreAuthorize.

  6. 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.tsx for the form, <Resource>Editor.tsx for the full edit screen, <Resource>Client.tsx for 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-For for 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:

  1. Pick the permission strings (<resource>:read, <resource>:edit).
  2. In the backend's SecurityConfig, add path matchers that require them.
  3. In the backend's seeded Role (Administrator), grant the new perms.
  4. Restart the backend - it logs all known permissions at boot.
  5. 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.log in committed code. Use console.warn for legit warnings.
  • Tailwind for styling. No CSS modules. Brand colors are in app/globals.css as 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.