Prodios Labs

$ ./projects/stackpress

04Intermediate

Stackpress

Stackpress is a developer-first headless CMS built for TanStack Start.

// docs

README

Stackpress

Stackpress is a developer-first headless CMS framework — think of it as your own Payload CMS built natively for TanStack Start. Instead of clicking through an admin panel to model content, developers describe their content in a single TypeScript config file — collections, fields, relationships, access rules — and Stackpress generates everything else: the admin UI with forms and list views, the database layer on MongoDB, a media library on S3-compatible storage, and a typed API for querying content from the app.

The core idea, borrowed straight from Payload's configuration model, is that the config is the CMS. A collection defined as { slug: 'posts', fields: [{ name: 'title', type: 'text' }, ...] } is simultaneously the database schema, the validation contract, the admin form definition, and the TypeScript type of a post. Change the config, and the whole system follows. And because Stackpress lives inside a TanStack Start app — the admin panel is just a route, the API is just server functions — the CMS and the site that consumes it are one deployable codebase, not two systems glued together over HTTP.

The goal of the hackathon project is to build a working slice of this framework: a config-driven CMS where defining a collection in code produces a usable admin panel (list view + create/edit forms) at /admin, documents stored in MongoDB, file uploads in S3-compatible storage, and a typed API the host app uses to render content.


What you are building

At a high level, a developer using Stackpress should be able to:

  1. Define content in config — write stackpress.config.ts exporting collections (posts, authors, media) with typed fields: text, rich text, number, checkbox, select, date, relationship, array, and upload.
  2. Get an admin panel for free — visit /admin and see every collection with a searchable, paginated list view and auto-generated create/edit forms — every field type rendered with the right input, validation included.
  3. Store documents in MongoDB — each collection maps to a MongoDB collection; documents are validated against the config on every write.
  4. Upload media to S3 — an upload-enabled collection gives you a media library: drag a file in, it lands in an S3-compatible bucket, and the document stores its key, size, mime type, and URL.
  5. Relate content — a relationship field on posts points at authors; the admin form renders a picker, and reads can populate the related document.
  6. Query from the app — pages in the same TanStack Start app fetch content through a typed local API (sp.find({ collection: 'posts', where: ... })) in loaders and server functions — no HTTP round-trip to an external CMS.
  7. Control access — auth for admin users, and per-collection access rules deciding who can read, create, update, or delete.

Architecture overview

Stackpress is a framework that lives inside a TanStack Start app. One config file feeds three consumers: the admin UI (forms), the data layer (validation + persistence), and the type generator (developer experience). MongoDB holds documents; the S3 bucket holds file binaries; the app itself queries content through the same data layer the admin uses.

Rendering diagram…

The pieces

1. The config (stackpress.config.ts) The single source of truth, mirroring Payload's shape:

export default defineConfig({
  collections: [
    {
      slug: 'posts',
      admin: { useAsTitle: 'title' },
      access: { read: () => true, create: ({ user }) => !!user },
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'content', type: 'richText' },
        { name: 'status', type: 'select', options: ['draft', 'published'] },
        { name: 'author', type: 'relationship', relationTo: 'authors' },
        { name: 'coverImage', type: 'upload', relationTo: 'media' },
      ],
    },
    {
      slug: 'authors',
      fields: [
        /* ... */
      ],
    },
    { slug: 'media', upload: true, fields: [{ name: 'alt', type: 'text' }] },
  ],
})

Everything downstream — forms, validation, storage, types — is derived from this object. Designing this schema (which field types exist, what properties each carries) is the most important design work in the project.

2. Config registry Loads the config at startup, validates it (unknown field types, duplicate slugs, relationships pointing at collections that don't exist — all fail fast with good errors), and exposes it to every other part of the system. This is also where field definitions are resolved into their runtime behavior: each field type contributes a validator, a default value, and an admin component.

3. The admin panel (TanStack Start routes) Not a separate app — a set of routes inside the host app (/admin, /admin/$collection, /admin/$collection/$id) guarded by auth. The panel reads the config registry and renders:

  • List views — a table per collection with the useAsTitle column, timestamps, pagination, sorting, and text search.
  • Forms — the heart of the project: a form renderer that walks a collection's fields array and maps each field type to a component (text → input, select → dropdown, checkbox → toggle, richText → editor, relationship → searchable picker fetching from the related collection, array → repeatable rows with add/remove/reorder, upload → drag-and-drop zone with preview).
  • Media library — a grid view of the upload collection with previews for images.

Because TanStack Start owns routing, the dynamic segments do the heavy lifting: one route file renders every collection's list, another renders every collection's form — the config decides what appears.

4. Local API (the data layer) Payload's killer feature, reproduced: a typed, in-process API used by both the admin panel and the public site. sp.find({ collection: 'posts', where: { status: { equals: 'published' } }, populate: ['author'] }) — no HTTP hop, just a function call that applies access control, runs validation, talks to MongoDB, and returns typed documents. Admin form submissions go through TanStack Start server functions that call this same API, so there is exactly one write path.

5. MongoDB (the document store) The natural fit for a config-driven CMS: collections in the config map 1:1 to MongoDB collections, and flexible documents mean a config change doesn't require migrations — new fields simply appear on new writes. Stackpress enforces shape at the application layer (the validation engine), not the database layer. Relationships are stored as ObjectId references and resolved at read time when populate is requested. A users collection (password hashed, sessions) powers admin auth.

6. Upload handler + S3-compatible storage File binaries never touch MongoDB. An upload-enabled collection stores metadata (filename, key, mime type, size, dimensions) as a document, while the bytes go to an S3-compatible bucket — MinIO in local dev via Docker, Cloudflare R2 or AWS S3 in production, all through the same @aws-sdk/client-s3 client since they share the API. Uploads flow through presigned URLs (browser → bucket directly, then confirm) or stream through a server function; reads are served via presigned GET URLs or a public bucket path.

7. Auth & access control Admin users live in MongoDB; sessions are cookie-based. Access control follows Payload's model: each collection declares access: { read, create, update, delete } as functions receiving { user } (and optionally the document) and returning a boolean — evaluated inside the Local API so every caller, admin or site, passes through the same rules.

8. Type generation The payoff for doing this in TypeScript: derive the document types from the config (Post, Author, Media) — either purely at the type level with generics and inference, or with a small codegen step — so sp.find({ collection: 'posts' }) returns Post[] and the site's loaders are fully typed against the CMS content.


How a document flows

Understanding this end-to-end path is the heart of the project:

  1. Define — a developer adds a posts collection to stackpress.config.ts and restarts the dev server. The registry validates the config; /admin now shows Posts in the sidebar.
  2. Create — an editor opens /admin/posts/create. The form renderer walks the field definitions and renders a text input, a rich text editor, a select, an author picker, and an image drop zone.
  3. Upload — the editor drops a cover image. The client requests a presigned URL from a server function, PUTs the file straight to the bucket, then creates a media document holding the key and metadata. The form now references that document's ID.
  4. Submit — the form posts to a server function, which calls sp.create({ collection: 'posts', data }). Access control checks the session user may create posts; the validation engine checks every field against its config (required, type, select options, relationship targets exist); the document is inserted into MongoDB with timestamps.
  5. Query — the public site's /blog route loader calls sp.find({ collection: 'posts', where: { status: { equals: 'published' } }, populate: ['author', 'coverImage'] }). Access rules run again (public read is allowed), relationships are resolved, and the loader returns typed Post[] — server-rendered by TanStack Start.
  6. Render — the page renders the post with the author's name and the cover image served from the bucket. Editor publishes, site updates, no deploy, no external CMS, one codebase.

Technologies to use

The whole stack is TypeScript / JavaScript end-to-end — pick what your team is fastest in within that ecosystem. The concepts matter more than the exact library.

LayerOptions
FrameworkTanStack Start (React) — file-based routing, loaders, server functions
RuntimeNode.js or Bun (TypeScript throughout)
DatabaseMongoDB via the official mongodb driver (skip Mongoose — the config is your schema layer)
File storageS3-compatible: MinIO (local, via Docker) → Cloudflare R2 / AWS S3 (prod), all through @aws-sdk/client-s3
Presigned URLs@aws-sdk/s3-request-presigner
ValidationZod — generate a Zod schema per collection from the field config
Admin UIReact + shadcn/ui or Tailwind for the panel; TanStack Table for list views; TanStack Form or react-hook-form
Rich textTiptap or Lexical (store as JSON, render to React on the site)
AuthCookie sessions with argon2 / bcrypt password hashing — hand-rolled or better-auth
Type generationTypeScript inference from the config object (generics), or a small codegen script emitting types.ts
Local devDocker Compose: mongo + minio — one command to a full stack

Concepts to understand

Before writing code, make sure the team is comfortable with these ideas.

Config-driven architecture

  • Configuration as the single source of truth — one object drives the forms, the validation, the storage, and the types; nothing is defined twice. Read Payload's config overview — you are reimplementing its spirit.
  • Schema design for the config itself — which field types exist, which properties each takes (required, defaultValue, options, relationTo, admin.description), and how they compose (arrays contain fields, which may contain arrays).
  • Interpreting config at runtime vs. compile time — the form renderer interprets it at runtime; the type generator exploits it at compile time. The same object serves both.
  • Fail-fast validation of the config — a typo'd field type should crash the dev server with a clear message, not silently render nothing.

TanStack Start

  • File-based routing & dynamic segments/admin/$collection/$id as one route file serving every collection's edit form.
  • Loaders — server-side data fetching per route; where the public site calls the Local API.
  • Server functions — type-safe RPC from client to server (createServerFn); the only write path from the admin forms into the data layer.
  • SSR + hydration — the public site is server-rendered from CMS content; the admin panel is a highly interactive client app on the same foundation.

Dynamic form generation

  • Renderer-per-field-type — a registry mapping type → React component, so adding a field type is one component + one validator.
  • Recursive rendering — array fields render lists of sub-forms; the renderer must call itself.
  • Form state at scale — dirty tracking, per-field errors mapped from server-side validation, and controlled inputs across dozens of dynamic fields.
  • Relationship pickers — async search against another collection, displaying useAsTitle, storing the ID.

MongoDB for content

  • Schemaless with app-layer validation — the database accepts anything; your validation engine is the gatekeeper. Understand what that buys (no migrations) and costs (old documents may predate new required fields).
  • References vs. embedding — relationships as ObjectId references resolved on read (populate), and when embedding (array fields) is right instead.
  • Query mapping — translating the Local API's where operators (equals, in, contains, greater_than) into MongoDB filter documents safely — never letting raw user input become a query operator.
  • Indexes — on slugs, on frequently filtered fields, and text indexes for admin search.

Object storage

  • Why binaries don't belong in the database — documents hold metadata; the bucket holds bytes.
  • The S3 API as a de-facto standard — one SDK, many providers (MinIO, R2, S3); endpoint + credentials are just config.
  • Presigned URLs — letting the browser upload/download directly with time-limited signed URLs, so large files never stream through your server.
  • Key design & lifecycle — content-addressed or prefixed keys, and deleting the object when its document is deleted (or deliberately not).

Auth & access control

  • Sessions for the admin — cookie-based sessions, password hashing, protecting every /admin route and every server function (UI guards are not security).
  • Access functions, not role enums — Payload's model: read: ({ user }) => user?.role === 'admin' || { status: { equals: 'published' } } — rules can return booleans or query constraints that filter results.
  • One enforcement point — access control lives in the Local API, so no caller can bypass it.

Suggested milestones

A realistic path for a hackathon, from smallest useful slice to full loop:

  1. Config → console — define the config schema and defineConfig(), load and validate a sample config with two collections, and prove the Local API round-trip against MongoDB: sp.create()sp.find() with config-driven Zod validation, from a script.
  2. First admin screens — TanStack Start app with /admin/$collection list view (TanStack Table, pagination) and a create/edit form supporting text, number, checkbox, and select — all rendered purely from config, writes via server functions.
  3. Auth — a users collection, login page, cookie sessions, and guards on all admin routes and server functions.
  4. Relationships & richer fields — the relationship picker with async search and populate on read; array fields with recursive rendering; a rich text field.
  5. Uploads — MinIO via Docker, an upload-enabled media collection, presigned-URL uploads with image previews, and upload fields referencing media documents. (This closes the full Payload-like loop.)
  6. Consume it — a public blog route in the same app whose loader queries published posts through the Local API and renders them (cover images from the bucket) — proving the "one codebase, CMS + site" promise. Then polish: access rules per collection, delete flows, search.

Stretch goals

  • Generated TypeScript types — full Post / Author types inferred from the config, making sp.find() end-to-end typesafe.
  • Drafts & versions — Payload-style version history per document with restore, and draft/published status built into the framework rather than a select field.
  • Globals — singleton config-defined documents (site settings, nav menus) alongside collections.
  • HooksbeforeChange / afterChange / afterDelete functions in the config for slug generation, cache busting, or webhooks.
  • REST API layer — auto-mounted /api/$collection routes exposing the Local API over HTTP for external consumers, with API keys.
  • Blocks field type — Payload's layout-builder field: an array where each row is one of several developer-defined block shapes, rendered by the site as page sections.
  • Image processing — generate resized variants on upload (via sharp) and store each size in the bucket.
  • Live preview — edit a draft in the admin with the public page rendering beside it, updating as you type.