Project structure
The three-tier layout every Systhema project shares and where your own code belongs.
On this page
Use these file-ownership rules to decide where to put your code. Payload projects have managed routes and starter scaffolds; HTML and Next.js projects without Payload do not have the managed CMS route group.
The three tiersLink to this section
Every file in src/ falls into exactly one bucket:
- Framework-managed: refreshed during
systhema upgrade, with local edits preserved for review beside a.newfile. Carries aDO NOT MODIFYbanner. Treat as part of@systhemaui/*; do not edit. - Starter scaffolds, written once at scaffold time, preserved on every subsequent upgrade. Yours to customize (logo, fonts, providers, metadata, 404 design). Carries a "Starter file scaffolded by Systhema" banner.
- Your territory, entirely yours. Custom blocks, components, lib utilities, access functions, custom routes, anything not produced by the framework.
The (systhema)/ route group and src/proxy.ts are framework-managed. The (payload)/ route group is Payload's own: create-app-files writes it once when it is missing and upgrades never rewrite it; edit only its custom.scss, and let payload generate:importmap rewrite admin/importMap.js. src/app/layout.tsx, src/app/(site)/template.tsx, src/app/(site)/globals.css, src/app/(site)/not-found.tsx, and src/app/robots.txt/route.ts are starter scaffolds. Everything else, src/blocks/, src/components/, src/lib/, src/access/, custom routes, is yours.
| Tier | Behaviour on upgrade | Examples |
|---|---|---|
| Framework-managed | Refreshed; local edits produce .new | src/proxy.ts, src/app/(site)/(systhema)/[[...segments]]/page.tsx, (systhema)/(sitemaps)/sitemap.xml/route.ts, (systhema)/sys/[route]/route.ts |
| Starter scaffolds | Written once, never overwritten | src/app/layout.tsx, src/app/robots.txt/route.ts, src/app/(site)/template.tsx, src/app/(site)/globals.css, src/app/(site)/not-found.tsx |
| Your territory | Untouched by Systhema | src/blocks/, src/components/, src/lib/, src/access/, src/app/about/page.tsx, anything you author |
With frontend locales enabledLink to this section
Configuring locales adds no glue files, src/proxy.ts and the catch-all page.tsx stay byte-identical. The one file that changes shape is the site shell, and the tiers still hold:
| File | Tier | Notes |
|---|---|---|
src/app/(site)/shell.tsx | Starter scaffold | Replaces template.tsx. Your fonts, branding, header/footer, receives the route locale as a prop. |
(site)/(systhema)/[[...segments]]/layout.tsx | Framework-managed | Boundary layout: resolves the route locale and injects it into your shell. |
(site)/(systhema)/[[...segments]]/not-found.tsx | Framework-managed | Re-exports your (site)/not-found.tsx so a thrown notFound() renders inside the shell. |
Your 404 stays where it was, src/app/(site)/not-found.tsx is still the file you edit. Disabling locales reverses all of this and restores template.tsx.
Project shapesLink to this section
The three shapes match the HTML, Next.js and Payload starter templates. Match the template, don't invent your own layout.
HTMLLink to this section
project-root/
├── index.html # entry — loads dist/style.css
├── package.json
├── systhema.config.js # JS variant (HTML projects don't have TS)
└── src/
├── style.css # @import '@systhemaui/core/tailwind'
└── tokens/ # design tokens (your territory)No router, no app shell. The HTML shape only consumes @systhemaui/core.
Next.js without CMSLink to this section
project-root/
├── next.config.ts
├── package.json
├── systhema.config.ts
├── public/
└── src/
├── app/
│ ├── globals.css # @import '@systhemaui/core/tailwind' ← starter scaffold
│ ├── layout.tsx # RootLayout — fonts, metadata, providers ← starter scaffold
│ └── page.tsx # home page — your territory
├── blocks/ # custom blocks — your territory (optional)
├── components/ # custom React components — your territory (optional)
├── lib/ # shared utilities — your territory (optional)
└── tokens/ # design tokensFlat src/app/, no route groups. Add src/blocks/, src/components/, src/lib/ when you need them. There is no (site)/ segment in a Next-only project; Payload is the reason (site)/ exists in the next shape.
Next.js + PayloadLink to this section
project-root/
├── next.config.ts # wraps in withPayload(...)
├── register-env.sh # loads .env into shell for systhema-core CLI
├── .env / .env.example # PAYLOAD_SECRET, DATABASE_URI, NEXT_PUBLIC_SERVER_URL
├── public/ # static assets + uploads/ symlink
├── storage/ # SQLite database + filesystem upload store
└── src/
├── payload.config.ts # buildConfig wrapped in withSysthema(...)
├── payload-types.ts # generated by `payload generate:types`
├── proxy.ts # systhemaGateway(...) ← framework-managed
├── tokens/ # design tokens
├── blocks/ # custom blocks — your territory (see "Block mirror pattern")
├── components/ # custom React components — your territory
├── lib/ # shared utilities — your territory
├── access/ # custom capabilities + access functions — your territory
└── app/
├── layout.tsx # outer layout — starter scaffold
├── robots.txt/route.ts # host-aware robots.txt — starter scaffold
├── (payload)/ # Payload admin segment — Payload's, written once
│ ├── layout.tsx
│ ├── custom.scss
│ ├── admin/
│ └── api/
└── (site)/ # public-facing site
├── globals.css # @import '@systhemaui/core/tailwind' ← starter scaffold
├── template.tsx # RootLayout + RootHeader + RootFooter ← starter scaffold
├── not-found.tsx # 404 page — starter scaffold
├── (systhema)/ # CMS-driven routes — framework-managed
│ ├── [[...segments]]/page.tsx
│ ├── (sitemaps)/sitemap.xml/route.ts
│ └── sys/[route]/route.ts
└── about/page.tsx # custom static page — your territory (example)The (site)/ segment exists so the public site can coexist with the (payload)/ admin segment; both share src/app/layout.tsx. Your custom pages mount under (site)/ so they inherit template.tsx's header/footer.
What's shared across all threeLink to this section
src/tokens/, always, every shape. Tokens are the design-system source of truth.systhema.config.{ts,js}at the project root, always, every shape.- "Your territory" folders (
src/blocks/,src/components/,src/lib/, etc.), always optional, always your call to create.
Block mirror patternLink to this section
A Systhema custom block has two halves: a Payload schema and a React component. Colocate them in one folder rather than splitting them across src/blocks/ and src/components/:
src/blocks/Hero/
├── index.ts # Payload schema — fields, lexical features, customBlocks registration
└── component.tsx # React renderer — receives Payload props, returns JSXRegister the block objects through buildConfig(withSysthema(baseConfig, options)), with options.customBlocks containing each schema and converter. See Custom blocks for the complete registration contract. The same colocation works in Next-only projects. src/blocks/Hero/component.tsx can be imported and rendered with hardcoded props until Payload arrives. The systhema:building-sites agent skill covers the pattern in detail.
lib/ over utils/Link to this section
One name for shared utilities: src/lib/, never src/utils/. Backend helpers, shared formatters, request/response builders all live here. Frontend-only utilities can colocate next to the consuming component (src/components/foo/utils.ts) or share via src/lib/ if cross-cutting.
This avoids the most common drift in real Systhema projects, half use lib/, half use utils/, a few use both.
Upgrading from Next-only to Next + PayloadLink to this section
Adding Payload to a Next-only project is mostly additive. Existing src/blocks/, src/components/, src/lib/ survive untouched; the integration adds Payload's app-router segment and the framework-managed routes.
| Step | What changes |
|---|---|
| 1. Install packages | Add @systhemaui/payload, payload, db driver (@payloadcms/db-sqlite, @payloadcms/db-postgres, …). |
2. Create src/payload.config.ts | New file. Wrap buildConfig({...}) in withSysthema(...). Set secret, db. |
3. Move src/app/page.tsx → src/app/(site)/page.tsx, same for globals.css | The only file-move step. Everything else is additive. |
4. Run systhema-core payload create-app-files | Scaffolds src/proxy.ts, src/app/(site)/(systhema)/, src/app/(site)/template.tsx, not-found.tsx, and src/app/(payload)/ when the project has none. Then run payload generate:importmap. |
| 5. Wire existing custom blocks | Add CustomBlock objects to customBlocks: [myBlock] in the plugin options. Each object provides type, data, editor and converter; register browser converters separately for client preview. |
6. Set packages: { payload: true } in systhema.config.ts | Activates Payload-specific token outputs and admin-UI utilities. |
7. Run pnpm sync | Regenerates artifacts and the configured Payload outputs. If you changed package versions, review the separate upgrade and database migration plan. |
The full guide, including diagnosis of what survives, the customBlocks wiring, and token migrations, is in the systhema:building-sites agent skill.
Customizing the content modelLink to this section
The editor's content model is the page. Start from the assumption that every page is a default page (the hero group plus one root content field composed from native blocks) and that the home page can use the same default template. Add a custom template only when its shell or data requirements differ. Add schema only where the system has no answer, and decide top down:
| You need… | Use |
|---|---|
| A page | A default page composed from native blocks. |
| A band repeated on several pages | components: true and the component block. |
| A page whose renderer needs data or a bespoke layout | A custom page template: one root content field, resolveData for the fetch. A listing page reuses the archive's editorTabs shape and createPostsListingFields(). |
| A kind of post | posts.types plus a thin post template (root content and the two or three typed fields the kind cannot render without). |
| A band no native block renders, or one that runs a query | A custom block, registered at the editors its nearest native block uses, using the same tier as the section or region its renderer supplies. Add design controls only when the native composition cannot express the requirement. |
| Data with its own identity, read from more than one place | A collection via customCollections. |
Typed template fields are for what the page breaks without (a listing's config, a hero, an embed URL); prose is blocks. Custom blocks used on exactly one page, typed prose fields, single-reader collections and a settings global that duplicates the Footer global are the shapes that get rebuilt later. withSysthema replaces the base config's collections and globals arrays with its own set plus customCollections / customGlobals; extend a Systhema-owned collection after the call, not by passing collections.
When in doubtLink to this section
- File is in
src/app/(site)/(systhema)/: framework-managed, keep your application code outside it.src/proxy.tsis also managed; preserve project security changes and review its.newfile during upgrades. - File is in
src/app/(payload)/→ Payload's route group, never rewritten by upgrades; edit onlycustom.scss. - File has a "Starter file scaffolded by Systhema" banner → starter scaffold, edit freely.
- File is anywhere else under
src/→ your territory.
The starter templates are the canonical layouts. Match them; don't invent.