< back to work > case_study

> · project

CyclingHero Admin

The admin panel where every form draws itself from a metadata endpoint

Internal admin for the CyclingHero ops team — a generic, metadata-driven form engine that turns any new content type into a working CRUD UI without writing a form.

Stack

  • Next.js 16
  • React
  • TypeScript
  • TailwindCSS
  • Radix UI
  • TanStack Query
  • Google maps API

Engagement

  • statuslive
  • modelretainer
  • since2022
  • > built and maintained alongside the main CyclingHero site

Links

  • > no public links

CyclingHero Admin is the internal panel the ops team uses to plan tours, manage bookings, build day plans, edit climbs and routes, and curate the content the customer-facing site reads. Sister project to the public CyclingHero site — same domain, different audience.

The headline: forms that build themselves

The interesting design decision is that no form is hard-coded. Every content type — tour, booking, route, climb, day plan, special event — exposes a /{contenttype}/__metadata endpoint that returns a list of field descriptors:

{
  key: "title",
  type: "richtext",
  required: true,
  show_in_list: true,
  // ...
}

A single <GenericEditForm /> component fetches the metadata + entity in parallel, groups fields into Basic / Media / Relationships, and a <FormField /> dispatcher renders the right component per type:

  • richtext → Tiptap editor with the toolbar plumbed in
  • assetdata / asseturl → asset picker with inline preview and drag-reorder
  • array → tag input or asset list (depending on itemtype)
  • child_relation → searchable foreign-key picker (modal, multi-select)
  • boolean → checkbox; datetime → date input; default → text
metadata.fields[]


┌─────────────────────┐
│  <GenericEditForm/> │  ── fetches metadata + entity
└──────────┬──────────┘
           │   for each field:

┌─────────────────────┐
│   <FormField        │
│      type={…} />    │
└──────────┬──────────┘

   ┌───────┼───────────────────────────┐
   ▼       ▼              ▼            ▼
richtext  assetdata     array     child_relation
  │         │             │             │
  Tiptap   AssetPicker   TagInput    RelationPicker
                                      (modal)
~/admin

$ tplocic stats --admin

  • field types in the dispatcher 8
  • loc in the generic form engine 655
  • hard-coded forms 0
~/admin/edit-form

Adding a new content type means adding a row in the metadata endpoint, not building a form. Dirty-state tracking, validation, save flow, delete flow, and field grouping all come for free.

Other bits

  • Multi-environment — every route is prefixed with /[env] (prod, test, app, local), so the same UI talks to dev/test/prod cleanly. Query keys are env-prefixed too, so caches don’t bleed across environments.
  • Iron-session auth at the middleware layer with public-path allowlists.
  • TanStack Table for the list views with sorting and filtering.
  • Mapbox GL for the route and climb editing surfaces.
  • Asset bundle loader — fetches assets in batch by key, keyed off a singleton AssetMap so the same asset isn’t requested 12 times on a busy page.

Role

Long-running engagement alongside the public CyclingHero site. The form engine and the metadata convention were built once and now power every CRUD screen the team uses day-to-day.