React Admin Site example
So you want to generate React admin sites from a DSL, not hand-craft each one. ProJor is perfect for that: you describe your models, data, and templates once, and ProJor turns that into a working codebase.
In this guide we’ll build a ProJor template repository that generates a React+TypeScript+Tailwind admin UI, including:
- A React + TypeScript + Tailwind CSS admin site
- Multiple page types: Dashboard, List, Edit
- A small DSL to define pages
- Layout components (header, sidebar, main area)
- A basic widget system for dashboards
- Documentation and Docker setup
We’ll start very simple and add complexity step by step, always keeping the same core idea:
You define the what in
.projor/, ProJor generates the how insrc/,public/, etc.
Click here to access the source code of the example.
Before We Start: Core ProJor Concepts
Let’s demystify some words we’ll keep using:
Schemas – Think of these as typed “classes” for your domain:
- Example:
Page,WidgetType,ListPageColumn. - Defined under
.projor/schema/*.pschema.yaml.
- Example:
Data collections – Concrete objects that follow a schema:
- Example:
PageTypeinstances (Dashboard, ListPage, EditPage). - Defined under
.projor/data/*.pdata.yaml.
- Example:
Templates – Mustache-like files that generate code:
- Example:
package-json.ptemplate.mustache→package.json. - Defined under
.projor/template/.
- Example:
Partials – Reusable template fragments:
- Example: reusable JSX bits for dashboards or list pages.
- Defined under
.projor/partials/.
Globals – Project-wide configuration you can access everywhere:
- Example: header/sidebar layout config, project name, etc.
- Defined as
*.pglobal.yamlin.projor/.
Languages – Custom DSL parsers:
- Example: a parser that turns
.pagesfiles intoPageobjects. - Defined under
.projor/language/*.plang.js.
- Example: a parser that turns
We’ll wire all of these together into a single coherent template repo.
Step 1: Base Project Setup
Directory Layout
We start by creating a .projor/ folder that holds everything ProJor needs to know:
.projor/
├── schema/ # Data structure definitions (Page, DashboardPage, etc.)
├── data/ # Predefined data collections (PageType, WidgetType, etc.)
├── template/ # Code generation templates (TSX files, package.json, Dockerfile…)
├── partials/ # Reusable template bits (dashboard/list/edit content)
└── language/ # Custom DSL parsers (our .pages DSL)Your actual app files (src/, public/, etc.) will be generated based on what’s inside .projor/.
Project Globals
Create .projor/project.pglobal.yaml:
scope: my-org
name: admin-site
description: My admin site
version: 0.0.1
initialPage: dashboardThese are “root” values accessible from any template:
$.scope$.name$.description$.version$.initialPage
We’ll use them for things like npm package name, initial route, and layout title.
First Template: package.json
Let’s generate a minimal package.json using ProJor.
Create .projor/template/package-json.ptemplate.mustache:
{
"map": {},
"filename": "package.json",
"formatUsing": "json"
}
---
{
"name": "{{#if $.scope}}@{{$.scope}}/{{/if}}{{kebabCase $.name}}",
"version": "{{{$.version}}}",
"description": "{{$.description}}",
"private": true,
"dependencies": {
"react": "19.2.0",
"react-dom": "19.2.0",
"@iconify/react": "6.0.2"
},
"devDependencies": {
"esbuild": "0.27.0",
"typescript": "5.9.3",
"@types/react": "19.2.5",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.1.17",
"@tailwindcss/cli": "4.1.17"
},
"scripts": {
"build:css": "pnpx @tailwindcss/cli -i src/tailwind.css -o public/index.css --minify",
"build:js": "esbuild src/index.tsx --bundle --outfile=public/index.js --minify --jsx=automatic",
"build": "pnpm run build:css && pnpm run build:js"
}
}What’s going on here:
map: {}– We don’t need to iterate or join collections; we just generate a single file.filename– The output path:package.json.formatUsing: "json"– ProJor will pretty-format the JSON for us.We use
{{$.scope}},{{$.name}},{{$.version}},{{$.description}}fromproject.pglobal.yaml.We include dependencies for React, TypeScript, Tailwind, and Iconify.
We define build scripts for:
build:css– Tailwind CLI to generatepublic/index.cssbuild:js–esbuildbundlingsrc/index.tsx→public/index.jsbuild– Runs both of the above
Run:
projor generateYou’ll get a ready-to-use package.json for the generated app.
Step 2: Page Types – Dashboard, List, Edit
We want different kinds of pages in our admin UI: dashboards, lists, and edit forms.
PageType Schema
Create .projor/schema/PageType.pschema.yaml:
id: PageType
name: Page Type
description: A type of page in the admin site
fields: []This schema is intentionally empty: we only care that a PageType has a name and description (which come from data objects), not extra fields.
PageType Data Collection
Create .projor/data/pagetype.pdata.yaml:
id: pagetype
name: Page Types
description: The supported types of pages in the admin site
schema: PageType
objects:
- name: Dashboard
description: A dashboard page
- name: ListPage
description: A page that lists items
- name: EditPage
description: A page that edits an itemNow we have three page types we can refer to elsewhere via references to PageType.
Step 3: Basic Page Schema + A Tiny DSL
Page Schema
Create .projor/schema/Page.pschema.yaml:
id: Page
name: Page
description: A page in the admin site
fields:
- name: type
description: The type of the page
type: reference
references: PageType
required: true
- name: title
description: The title of the page
type: string
required: true
- name: message
description: The message to display on the page
type: string
required: true
- name: icon
description: The icon to display on the page
type: string
required: true
- name: dashboard
type: attachment
references: DashboardPage
- name: listPage
type: attachment
references: ListPage
- name: editPage
type: attachment
references: EditPage
- name: unlisted
type: boolean
description: Whether to hide from navigationKey ideas:
type: reference+references: PageTypemeans everyPagepoints to one of ourPageTypeobjects.dashboard,listPage,editPageare attachments to more specific schemas (we’ll define those later).unlistedwill let us hide certain pages from sidebar navigation.
Our Page DSL: .projor/.pages
Instead of editing Page objects by hand, we’ll define them in a human-friendly DSL.
Create .projor/.pages:
dashboard Dashboard[ic:baseline-dashboard] {
title Admin Dashboard
message Welcome to the admin site
stat widget Revenue[$1,000,000] { }
}
list_page Products[ic:baseline-shopping-cart] {
title Products
message List of all products
column id: number example 123
column name: string example Product Name
column price: number example 99.99
item action Edit[ic:baseline-edit] goes to EditProduct
item action Delete[ic:baseline-delete] goes to Products
}
edit_page EditProduct[ic:baseline-edit]<unlisted> {
title Edit Product
message Edit the product details
field name: string example Product Name
field price: number example 99.99
item action Save[ic:baseline-save] goes to Products
item action Cancel[ic:baseline-cancel] goes to Products
}The DSL is intentionally lightweight:
dashboard,list_page,edit_pageindicate the page type.Name[icon-id]sets the page name and icon.{ ... }contains fields liketitle,message, and type-specific stuff (widgets, columns, fields).<unlisted>marks a page that should not appear in the sidebar.
The DSL Parser
We need to turn .pages into a ProJor collection of Page objects.
Create .projor/language/pages.plang.js:
function extractField(body, fieldName) {
const regex = new RegExp(fieldName + '\\s+([^\\n]+)');
const match = body.match(regex);
return match ? match[1].trim() : '';
}
async function parseAllPagesFiles(files) {
const result = {
schema: "Page",
objects: []
};
for (const file of files) {
const content = file.content;
// DASHBOARD PAGES
const dashboardRegex = /dashboard\s+(\w+)\[([^\]]+)\](\<unlisted\>)?\s*\{([^}]+)\}/gs;
let match;
while ((match = dashboardRegex.exec(content)) !== null) {
const [, name, icon, unlistedFlag, body] = match;
const page = {
name: name,
type: "pagetype#Dashboard",
title: extractField(body, "title"),
message: extractField(body, "message"),
icon: icon.trim(),
unlisted: !!unlistedFlag,
dashboard: {
widgets: [],
preActions: [],
postActions: []
}
};
const statRegex = /stat widget\s+([^\[]+)\[([^\]]+)\]/g;
let statMatch;
while ((statMatch = statRegex.exec(body)) !== null) {
page.dashboard.widgets.push({
type: "widgettype#Stat",
statWidget: {
label: statMatch[1].trim(),
value: statMatch[2].trim()
}
});
}
// Pre/post actions can be added here similarly if needed
result.objects.push(page);
}
// LIST PAGES
const listPageRegex = /list_page\s+(\w+)\[([^\]]+)\](\<unlisted\>)?\s*\{([^}]+)\}/gs;
while ((match = listPageRegex.exec(content)) !== null) {
const [, name, icon, unlistedFlag, body] = match;
const page = {
name: name,
type: "pagetype#ListPage",
title: extractField(body, "title"),
message: extractField(body, "message"),
icon: icon.trim(),
unlisted: !!unlistedFlag,
listPage: {
columns: [],
itemActions: [],
preActions: [],
postActions: []
}
};
const columnRegex = /column\s+(\w+):\s*(\w+)\s+example\s+([^\n]+)/g;
let columnMatch;
while ((columnMatch = columnRegex.exec(body)) !== null) {
page.listPage.columns.push({
name: columnMatch[1],
type: `basic#${columnMatch[2]}`,
example: columnMatch[3].trim()
});
}
const actionRegex = /item action\s+([^\[]+)\[([^\]]+)\]\s+goes to\s+(\w+)/g;
let actionMatch;
while ((actionMatch = actionRegex.exec(body)) !== null) {
page.listPage.itemActions.push({
name: actionMatch[1].trim(),
icon: actionMatch[2].trim(),
goesTo: `pages#${actionMatch[3]}`
});
}
// Optional: pre/post actions using `pre action`/`post action` syntax
result.objects.push(page);
}
// EDIT PAGES
const editPageRegex = /edit_page\s+(\w+)\[([^\]]+)\](\<unlisted\>)?\s*\{([^}]+)\}/gs;
while ((match = editPageRegex.exec(content)) !== null) {
const [, name, icon, unlistedFlag, body] = match;
const page = {
name: name,
type: "pagetype#EditPage",
title: extractField(body, "title"),
message: extractField(body, "message"),
icon: icon.trim(),
unlisted: !!unlistedFlag,
editPage: {
fields: [],
postActions: [],
preActions: []
}
};
const fieldRegex = /field\s+(\w+):\s*(\w+)\s+example\s+([^\n]+)/g;
let fieldMatch;
while ((fieldMatch = fieldRegex.exec(body)) !== null) {
page.editPage.fields.push({
name: fieldMatch[1],
type: `basic#${fieldMatch[2]}`,
example: fieldMatch[3].trim()
});
}
const actionRegex = /item action\s+([^\[]+)\[([^\]]+)\]\s+goes to\s+(\w+)/g;
let actionMatch;
while ((actionMatch = actionRegex.exec(body)) !== null) {
page.editPage.postActions.push({
name: actionMatch[1].trim(),
icon: actionMatch[2].trim(),
goesTo: `pages#${actionMatch[3]}`
});
}
result.objects.push(page);
}
}
return result;
}
async function parse(files) {
return await parseAllPagesFiles(files);
}
module.exports = { parse };You don’t need to love regex to use this; you just need to know it turns our DSL into objects of schema Page.
Step 4: Generating Page Components & Entry Point
Page Components (src/pages/*.page.tsx)
Each Page becomes a React component. We’ll refine this later with layouts and content partials, but let’s start simple.
Create .projor/template/page-tsx.ptemplate.mustache:
{
"forEach": "pages",
"filename": "src/pages/{{kebabCase name}}.page.tsx",
"formatUsing": "typescript"
}
---
import { AppLayout } from "../layout/AppLayout.component";
import { AppDashboardStat } from "../components/AppDashboardStat.component";
import { AppAction } from "../components/AppAction.component";
{{#if dashboard}}
{{> dashboard-parts page=this }}
{{/if}}
{{#if listPage}}
{{> list-page-parts page=this }}
{{/if}}
{{#if editPage}}
{{> edit-page-parts page=this }}
{{/if}}
export function {{pascalCase name}}Page(opts: { navigate: (page: string) => void }) {
return (
<AppLayout
navigate={opts.navigate}
content={() => (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">{{title}}</h1>
<p className="text-gray-600">{{message}}</p>
{{#if dashboard}}
<{{pascalCase name}}Content />
{{/if}}
{{#if listPage}}
<{{pascalCase name}}Content navigate={opts.navigate} />
{{/if}}
{{#if editPage}}
<{{pascalCase name}}Content navigate={opts.navigate} />
{{/if}}
</div>
)}
/>
);
}forEach: "pages"– one file perPage.- We import
AppLayoutand, depending on page type, use partials for content. - Every page receives a
navigate(page: string)function so it can trigger navigation via actions.
Entry Point: src/index.tsx
We now tie all pages together and implement a tiny “router” based on state.
Create .projor/template/index-tsx.ptemplate.mustache:
{
"map": { "p": "pages" },
"filename": "src/index.tsx",
"formatUsing": "typescript"
}
---
import { createRoot } from "react-dom/client";
import { useState } from "react";
{{#each p}}
import { {{pascalCase name}}Page } from "./pages/{{kebabCase name}}.page";
{{/each}}
function App() {
const [currentPage, setCurrentPage] = useState("{{kebabCase $.initialPage}}");
const navigate = (page: string) => setCurrentPage(page);
return (
<div className="min-h-screen">
{{#each p}}
{currentPage === "{{kebabCase name}}" && (
<{{pascalCase name}}Page navigate={navigate} />
)}
{{/each}}
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);This is intentionally simple:
- Internal state
currentPage navigate()changes it- Pages render conditionally based on
currentPage
You can swap this out later for React Router; the template is decoupled from the concept.
Step 5: Layout Components & Globals
We don’t want each page to re-build header and sidebar. Let’s generate a reusable layout.
Layout Globals
Create .projor/header.pglobal.yaml:
header:
enabled: true
background: slate-800
text: whiteCreate .projor/sidebar.pglobal.yaml:
sidebar:
enabled: true
background: slate-900
text: slate-100
width: 64These will control whether header/sidebar show up and their basic Tailwind colors/dimensions.
App Layout Component
Create .projor/template/app-layout-tsx.ptemplate.mustache:
{
"map": { "pages": "pages" },
"filename": "src/layout/AppLayout.component.tsx",
"formatUsing": "typescript"
}
---
import { ReactNode } from "react";
{{#if $.header.enabled}}
function AppHeader() {
return (
<header className="bg-{{$.header.background}} text-{{$.header.text}} p-4">
<h1 className="text-2xl font-bold">{{capitalCase $.name}}</h1>
</header>
);
}
{{/if}}
{{#if $.sidebar.enabled}}
function AppSidebar(opts: { navigate: (page: string) => void }) {
return (
<aside className="w-{{$.sidebar.width}} bg-{{$.sidebar.background}} text-{{$.sidebar.text}} p-4">
<nav className="space-y-2">
{{#each pages}}
{{#unless unlisted}}
<button
className="block w-full text-left hover:underline"
onClick={() => opts.navigate("{{kebabCase name}}")}
>
{{capitalCase name}}
</button>
{{/unless}}
{{/each}}
</nav>
</aside>
);
}
{{/if}}
export function AppLayout(opts: {
navigate: (page: string) => void;
content: () => ReactNode;
}) {
return (
<div className="flex flex-col min-h-screen">
{{#if $.header.enabled}}
<AppHeader />
{{/if}}
<div className="flex flex-1">
{{#if $.sidebar.enabled}}
<AppSidebar navigate={opts.navigate} />
{{/if}}
<main className="flex-1">
{opts.content()}
</main>
</div>
</div>
);
}Highlights:
- Layout is controlled purely via globals (
$.header,$.sidebar). - Sidebar is auto-generated from
pages, but hides pages withunlisted: true. - All pages share the same layout via
AppLayout.
Step 6: Dashboard Pages & Widgets
Now we want dashboards with widgets (e.g., stat tiles, lists, messages).
DashboardPage Schema
Create .projor/schema/DashboardPage.pschema.yaml:
id: DashboardPage
name: Dashboard Page
description: A dashboard page
fields:
- name: widgets
description: Widgets to display on the dashboard
type: attachment
references: DashboardWidget
multiple: true
- name: preActions
type: attachment
references: Action
multiple: true
- name: postActions
type: attachment
references: Action
multiple: trueWidgetType Schema & Data
Create .projor/schema/WidgetType.pschema.yaml:
id: WidgetType
name: Widget Type
description: A type of widget
fields: []Create .projor/data/widgettype.pdata.yaml:
id: widgettype
name: Widget Types
schema: WidgetType
objects:
- name: Stat
description: A statistic widget
- name: List
description: A list widget
- name: Message
description: A message widgetDashboardWidget Schema
Create .projor/schema/DashboardWidget.pschema.yaml:
id: DashboardWidget
name: Dashboard Widget
description: A widget on the dashboard
fields:
- name: type
type: reference
references: WidgetType
required: true
- name: statWidget
type: attachment
references: StatWidget
- name: listWidget
type: attachment
references: ListWidget
- name: messageWidget
type: attachment
references: MessageWidgetSpecific Widget Schemas
Create .projor/schema/StatWidget.pschema.yaml:
id: StatWidget
name: Stat Widget
description: A statistic display widget
fields:
- name: label
type: string
description: The label for the statistic
- name: value
type: string
description: The value to displayCreate .projor/schema/ListWidget.pschema.yaml:
id: ListWidget
name: List Widget
description: A list widget
fields:
- name: items
type: attachment
references: ListWidgetItem
multiple: trueCreate .projor/schema/ListWidgetItem.pschema.yaml:
id: ListWidgetItem
name: List Widget Item
fields: []Dashboard Stat Component
Create .projor/template/app-dashboard-stat-tsx.ptemplate.mustache:
{
"map": {},
"filename": "src/components/AppDashboardStat.component.tsx",
"formatUsing": "typescript"
}
---
export function AppDashboardStat(props: {
label: string;
value: string;
}) {
return (
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm text-gray-600">{props.label}</div>
<div className="text-3xl font-bold">{props.value}</div>
</div>
);
}Dashboard Content Partial
Create .projor/partials/dashboard-parts.partial.mustache:
function {{pascalCase page.name}}Content() {
return (
<div className="grid grid-cols-3 gap-4">
{{#each page.dashboard.widgets}}
{{#if statWidget}}
<AppDashboardStat
label="{{statWidget.label}}"
value="{{statWidget.value}}"
/>
{{/if}}
{{/each}}
</div>
);
}This partial is included in the page-tsx template (we already wired it up earlier). It knows how to render Stat widgets. You can add other widget types later using the same structure.
Step 7: List Pages
We now define list pages: basically tables with columns and example rows.
Basic Types
Create .projor/schema/BasicType.pschema.yaml:
id: BasicType
name: Basic Type
description: A basic data type
fields:
- name: ts
type: string
required: trueCreate .projor/data/basic.pdata.yaml:
id: basic
name: Basic Types
schema: BasicType
objects:
- name: string
ts: string
- name: number
ts: number
- name: boolean
ts: booleanThese let us refer to basic types (string, number, boolean) from other schemas.
ListPage & ListPageColumn Schemas
Create .projor/schema/ListPage.pschema.yaml:
id: ListPage
name: List Page
description: A list page showing a table of items
fields:
- name: columns
type: attachment
references: ListPageColumn
multiple: true
- name: itemActions
type: attachment
references: Action
multiple: true
- name: preActions
type: attachment
references: Action
multiple: true
- name: postActions
type: attachment
references: Action
multiple: trueCreate .projor/schema/ListPageColumn.pschema.yaml:
id: ListPageColumn
name: List Page Column
description: A column in a list page
fields:
- name: type
type: reference
references: BasicType
required: true
- name: example
type: stringWe already updated Page.pschema.yaml earlier to add listPage.
List Page Content Partial
Create .projor/partials/list-page-parts.partial.mustache:
function {{pascalCase page.name}}Content(opts: { navigate: (page: string) => void }) {
return (
<div className="space-y-4">
<div className="flex gap-2">
{{#each page.listPage.preActions}}
<AppAction
label="{{name}}"
icon="{{icon}}"
onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
/>
{{/each}}
</div>
<table className="w-full border-collapse">
<thead>
<tr>
{{#each page.listPage.columns}}
<th className="border-b py-2 text-left">{{capitalCase name}}</th>
{{/each}}
{{#if page.listPage.itemActions.length}}
<th className="border-b py-2 text-left">Actions</th>
{{/if}}
</tr>
</thead>
<tbody>
<tr>
{{#each page.listPage.columns}}
<td className="border-b py-2">{{example}}</td>
{{/each}}
{{#if page.listPage.itemActions.length}}
<td className="border-b py-2">
<div className="flex gap-2">
{{#each page.listPage.itemActions}}
<AppAction
label="{{name}}"
icon="{{icon}}"
onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
/>
{{/each}}
</div>
</td>
{{/if}}
</tr>
</tbody>
</table>
<div className="flex gap-2">
{{#each page.listPage.postActions}}
<AppAction
label="{{name}}"
icon="{{icon}}"
onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
/>
{{/each}}
</div>
</div>
);
}Now your list pages show:
- Optional pre-actions above the table
- A table with example data
- Per-row item actions
- Optional post-actions below the table
Step 8: Actions and Navigation
We want structured “buttons” that can navigate between pages.
Action Schema
Create .projor/schema/Action.pschema.yaml:
id: Action
name: Action
description: An action button
fields:
- name: goesTo
type: reference
references: Page
required: true
- name: icon
type: string
required: trueWe already hooked actions into DashboardPage, ListPage, and EditPage schemas (pre/post/item actions).
Actions in DSL
Example inside .projor/.pages (already shown earlier):
list_page Products[ic:baseline-shopping-cart] {
title Products
message List of all products
column id: number example 123
column name: string example Product Name
column price: number example 99.99
item action Edit[ic:baseline-edit] goes to EditProduct
item action Delete[ic:baseline-delete] goes to Products
}…and for edit page:
edit_page EditProduct[ic:baseline-edit]<unlisted> {
title Edit Product
message Edit the product details
field name: string example Product Name
field price: number example 99.99
item action Save[ic:baseline-save] goes to Products
item action Cancel[ic:baseline-cancel] goes to Products
}The parser maps those to Action objects referencing the corresponding Page.
Action Component
Create .projor/template/app-action-tsx.ptemplate.mustache:
{
"map": {},
"filename": "src/components/AppAction.component.tsx",
"formatUsing": "typescript"
}
---
import { Icon } from "@iconify/react";
export function AppAction(props: {
label: string;
icon: string;
onClick: () => void;
}) {
return (
<button
onClick={props.onClick}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
<Icon icon={props.icon} />
{props.label}
</button>
);
}This is a generic “button with icon” we reuse everywhere for actions.
Step 9: Edit Pages
Edit pages display a form with fields and “Save/Cancel” style actions.
EditPage Schemas
Create .projor/schema/EditPage.pschema.yaml:
id: EditPage
name: Edit Page
description: An edit page for editing items
fields:
- name: fields
type: attachment
references: EditPageField
multiple: true
- name: postActions
type: attachment
references: Action
multiple: true
- name: preActions
type: attachment
references: Action
multiple: trueCreate .projor/schema/EditPageField.pschema.yaml:
id: EditPageField
name: Edit Page Field
description: A field in an edit form
fields:
- name: type
type: reference
references: BasicType
required: true
- name: example
type: stringWe’ve already used these in the parser and Page schema.
Edit Page Content Partial
Create .projor/partials/edit-page-parts.partial.mustache:
function {{pascalCase page.name}}Content(opts: { navigate: (page: string) => void }) {
return (
<form className="space-y-4">
{{#each page.editPage.fields}}
<div>
<label className="block text-sm font-medium mb-1">
{{capitalCase name}}
</label>
<input
type="{{#equal type.name "number"}}number{{else}}text{{/equal}}"
defaultValue="{{example}}"
className="mt-1 block w-full rounded border-gray-300 px-3 py-2"
/>
</div>
{{/each}}
<div className="flex gap-2">
{{#each page.editPage.postActions}}
<AppAction
label="{{name}}"
icon="{{icon}}"
onClick={() => opts.navigate("{{kebabCase goesTo.name}}")}
/>
{{/each}}
</div>
</form>
);
}- Field type is chosen based on
BasicType(number→<input type="number">, everything else →text). - Actions call
navigate()with the target page’s kebab-case name.
Step 10: Tailwind CSS Wiring
We already used Tailwind classes in components; now we make Tailwind build work.
Tailwind Config Template
Create .projor/template/tailwind-config-js.ptemplate.mustache:
{
"map": {},
"filename": "tailwind.config.js",
"formatUsing": "javascript"
}
---
export default {
content: [
"./src/**/*.{ts,tsx}"
]
};Tailwind Entry CSS
Create .projor/template/tailwind-css.ptemplate.mustache:
{
"map": {},
"filename": "src/tailwind.css"
}
---
@import "tailwindcss";Combined with the package.json scripts we already defined, this gives you:
pnpm run build:css→ buildspublic/index.cssusing Tailwind CLIpnpm run build:js→ buildspublic/index.jsvia esbuildpnpm run build→ both of the above
Step 11: Documentation Generation
We don’t just want code – we want docs that explain the generated app itself.
README Template
Create .projor/template/readme-md.ptemplate.mustache:
{
"map": { "p": "pages" },
"filename": "README.md"
}
---
# {{capitalCase $.name}}
{{$.description}}
## Pages
This application contains {{p.length}} pages:
{{#each p}}
### {{capitalCase name}}
{{#unless unlisted}}
**Navigation:** Available in sidebar
{{else}}
**Navigation:** Unlisted (accessed via links)
{{/unless}}
{{#if dashboard}}
**Type:** Dashboard
Widgets:
{{#each dashboard.widgets}}
- {{#if statWidget}}Stat: {{statWidget.label}}{{/if}}
{{#if listWidget}}List: {{name}}{{/if}}
{{/each}}
{{/if}}
{{#if listPage}}
**Type:** List Page
Columns:
{{#each listPage.columns}}
- {{name}} ({{type.name}})
{{/each}}
{{/if}}
{{#if editPage}}
**Type:** Edit Page
Fields:
{{#each editPage.fields}}
- {{name}} ({{type.name}})
{{/each}}
{{/if}}
{{/each}}
## Development
```bash
pnpm install
pnpm run build
```This README is derived purely from the model and stays in sync with your pages.
Pages Reference
Create .projor/template/pages-md.ptemplate.mustache:
{
"map": { "p": "pages" },
"filename": "PAGES.md"
}
---
# Pages Reference
{{#each p}}
## {{capitalCase name}}
- **Title:** {{title}}
- **Message:** {{message}}
- **Icon:** {{icon}}
- **Type:** {{type.name}}
{{#if unlisted}}
- **Unlisted:** Yes
{{/if}}
{{/each}}This gives you a quick technical overview: name, title, message, icon, type, and unlisted flag.
Step 12: Docker Support
We’ll also generate Docker files so you can run the built app behind Nginx.
Dockerfile Template
Create .projor/template/dockerfile.ptemplate.mustache:
{
"map": {},
"filename": "Dockerfile"
}
---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install
COPY . .
RUN pnpm run build
FROM nginx:alpine
COPY --from=builder /app/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80docker-compose Template
Create .projor/template/docker-compose-yaml.ptemplate.mustache:
{
"map": {},
"filename": "docker-compose.yaml"
}
---
services:
web:
build: .
ports:
- "8080:80"
environment:
- NODE_ENV=productionYou can add the nginx.conf file separately (not part of this template), and you’ll have a one-command way to run the static built app.
Step 13: Advanced Features – Unlisted & Action Positioning
We’ve already wired most of this, but let’s recap the two advanced concepts.
Unlisted Pages
Pageschema has a booleanunlistedfield.In
.pages, you can mark a page as unlisted using<unlisted>:textedit_page EditProduct[ic:baseline-edit]<unlisted> { ... }The parser detects
<unlisted>and setspage.unlisted = true.The sidebar template (
AppSidebar) uses{{#unless unlisted}}to hide such pages from navigation.
This is useful for pages reachable only via deep links or actions, not the main menu.
PreActions & PostActions
For DashboardPage, ListPage, and EditPage we added:
preActions– actions rendered before the main contentpostActions– actions rendered after the main content
The DSL can support patterns like:
list_page Products[ic:baseline-shopping-cart] {
title Products
pre action Create[ic:baseline-add] goes to CreateProduct
# ... columns ...
post action Dashboard[ic:baseline-dashboard] goes to Dashboard
}The parser maps those pre action / post action expressions into the corresponding arrays, and the partials (dashboard-parts, list-page-parts, edit-page-parts) render them appropriately.
Full Workflow: How You Actually Use This
Define your pages in DSL
Edit
.projor/.pagesand describe your dashboard, list, and edit pages:textdashboard Dashboard[ic:baseline-dashboard] { title My Dashboard message Welcome! stat widget Users[1,234] { } } list_page Items[ic:baseline-list] { title Items message All items column name: string example Example }Adjust layout & globals
Tweak
.projor/header.pglobal.yaml,.projor/sidebar.pglobal.yaml, andproject.pglobal.yamlto match your app branding and layout preferences.Generate the code
bashprojor generateThis produces:
src/index.tsxsrc/layout/AppLayout.component.tsxsrc/components/*src/pages/*.page.tsxtailwind.config.jssrc/tailwind.csspackage.jsonREADME.md,PAGES.mdDockerfile,docker-compose.yaml
Build & run
bashpnpm install pnpm run buildOptionally, run via Docker:
bashdocker compose up --build
Conceptual Recap
Schema Design
Start with small, composable schemas:
PageType,BasicType,WidgetType.
Use references for relationships:
Page.type → PageType,ListPageColumn.type → BasicType, etc.
Use attachments for embedded structured data:
Page.dashboard → DashboardPage,DashboardPage.widgets → DashboardWidget.
Data Collections
Use
.pdata.yamlfiles for fixed sets:pagetype,widgettype,basic.
Reference them using
type: reference, references: SchemaName.
Templates & Partials
Use
forEachwhen generating many files (page-tsx).Use
mapwhen generating a single file that needs access to collections (index-tsx,README).Split complex JSX into partials:
dashboard-parts.partial.mustachelist-page-parts.partial.mustacheedit-page-parts.partial.mustache
Use
formatUsingto keep code tidy:typescript,json,javascript.
DSL & Language
The
.pagesDSL is a thin, human-friendly layer:dashboard,list_page,edit_pageblocks.title,message,column,field,stat widget,item action.
The
.plang.jsparser converts this DSL into strict ProJor objects.Everything else (components, README, Dockerfile) is generated from that model.
Globals & Config
Project globals (
project.pglobal.yaml) capture:- Naming, versioning, initial page, etc.
Layout globals (
header.pglobal.yaml,sidebar.pglobal.yaml) capture:- Header/sidebar presence and styles.
All are accessible in templates as
{{$.something}}.
Why This Repository Works Well
Layered abstraction
- Schemas → structure
- Data → instances
- DSL → human-friendly editing
- Templates → output code
Separation of concerns
- Page structure vs layout vs widgets vs actions vs docs vs Docker are all separate concerns, yet tied together by the ProJor model.
Configuration over code
- Want to hide a page from navigation? Add
<unlisted>, no TSX edits. - Want to change header color? Change
header.pglobal.yaml.
- Want to hide a page from navigation? Add
Incremental extension
- Add a new widget type? Define schema, extend
WidgetTypedata, add a component & partial. - Add a new page type? Add schema, add to
PageTypedata, extend the parser and templates.
- Add a new widget type? Define schema, extend
Single source of truth
.projor/is the brain.- The React app, documentation, and Docker setup are all projections of that.
Where to Go Next
From here, you can extend the template while keeping the same architecture:
New page types
- E.g., a “details” page or “wizard” page.
More widget types
- Charts, counters with trends, alerts.
Authentication
- Generate a login page and wire it into your layout.
API integration
- Generate API client code side-by-side with your UI pages.
Styling & theming
- Introduce themes via additional globals and Tailwind classes.
All of that can be layered on top of the structure you already have – no need to rewrite the generator, just keep evolving .projor/.