Specora Core generates a complete Next.js 15 frontend from your domain contracts. Page contracts define what to show. Entity contracts define the data model. Workflow contracts define state machines. Route contracts define the API. The generator produces a working app with typed API client, sortable data tables, drag-and-drop Kanban boards, entity forms with reference dropdowns, detail views, navigation sidebar, dashboard, and Docker deployment – all from YAML.
The NextJSGenerator produces approximately 26 files per domain (varies by entity count):
| File | Source | Description |
|---|---|---|
frontend/package.json |
gen_scaffold.py |
Next.js 15, React 18, Tailwind CSS, lucide-react, clsx, CVA |
frontend/next.config.js |
gen_scaffold.py |
Standalone output mode for Docker |
frontend/tailwind.config.js |
gen_scaffold.py |
Content path: ./src/**/*.{ts,tsx} |
frontend/postcss.config.js |
gen_scaffold.py |
Tailwind + autoprefixer |
frontend/tsconfig.json |
gen_scaffold.py |
ES2017, bundler module resolution, @/* path alias |
frontend/src/lib/utils.ts |
gen_scaffold.py |
cn() (clsx + tailwind-merge), formatDate(), formatDateTime(), truncate() |
| File | Source | Description |
|---|---|---|
frontend/src/lib/api.ts |
gen_api_client.py |
Typed fetch wrapper with methods per route contract |
| File | Source | Description |
|---|---|---|
frontend/src/components/ui/button.tsx |
gen_components.py |
Variants: default, destructive, outline, ghost. Sizes: default, sm, lg |
frontend/src/components/ui/input.tsx |
gen_components.py |
Styled text input with focus ring |
frontend/src/components/ui/badge.tsx |
gen_components.py |
Color-mapped badges (critical=red, high=orange, medium=yellow, low=green, etc.) |
frontend/src/components/ui/card.tsx |
gen_components.py |
Card, CardHeader, CardTitle, CardContent |
frontend/src/components/ui/select.tsx |
gen_components.py |
Styled native select |
frontend/src/components/ui/table.tsx |
gen_components.py |
Table, TableHeader, TableBody, TableRow, TableHead, TableCell |
frontend/src/app/globals.css |
gen_components.py |
Tailwind directives + system font stack |
For each entity that has a Page contract:
| File | Source | Description |
|---|---|---|
frontend/src/components/{Entity}Table.tsx |
gen_components.py |
Sortable data table with columns from the Page contract’s table view |
frontend/src/components/{Entity}Form.tsx |
gen_components.py |
Create/edit form with type-mapped inputs and reference dropdowns |
frontend/src/components/{Entity}Detail.tsx |
gen_components.py |
Read-only detail view with all fields |
frontend/src/components/{Entity}Kanban.tsx |
gen_components.py |
Drag-and-drop Kanban board (only if entity has a state machine) |
| File | Source | Description |
|---|---|---|
frontend/src/components/AppSidebar.tsx |
gen_components.py |
Navigation sidebar with links for each page, active state highlighting |
For each Page contract:
| File | Source | Description |
|---|---|---|
frontend/src/app/{route}/page.tsx |
gen_pages.py |
List page with table/kanban toggle, create button, total count |
frontend/src/app/{route}/[id]/page.tsx |
gen_pages.py |
Detail page with back/delete buttons |
frontend/src/app/{route}/new/page.tsx |
gen_pages.py |
Create page with entity form |
| File | Source | Description |
|---|---|---|
frontend/src/app/layout.tsx |
gen_layout.py |
Root layout: sidebar + main content area |
frontend/src/app/page.tsx |
gen_layout.py |
Dashboard with entity count cards |
frontend/Dockerfile.frontend |
gen_layout.py |
Multi-stage build: install, build, standalone runner |
frontend/.dockerignore |
gen_layout.py |
Excludes node_modules, .next, .git |
| File | Source | Description |
|---|---|---|
frontend/src/lib/types.ts |
TypeScriptGenerator |
TypeScript interfaces for all entities |
The Page contract defines what the list page looks like:
# Page contract
spec:
route: /tickets
title: Support Tickets
entity: entity/helpdesk/ticket
views:
- type: table
default: true
columns: [subject, priority, customer_id, assigned_agent_id]
- type: kanban
card_fields: [subject, priority]
The generator reads views to determine:
table view’s columns)kanban view’s card_fields)table and kanban views exist, a toggle button is generatedIf a page has both views, the list page includes Table/Kanban toggle buttons. If only one view type exists, that view is rendered directly.
The Entity contract’s fields determine form inputs and table cells:
| Field Type | Form Input | Table Cell |
|---|---|---|
string |
<input type="text"> |
Plain text |
text |
<textarea> |
Plain text |
integer |
<input type="number" step="1"> |
Plain text |
number |
<input type="number" step="any"> |
Plain text |
boolean |
<input type="checkbox"> |
Plain text |
email |
<input type="email"> |
Plain text |
date / datetime |
<input type="date"> |
Plain text |
uuid (with references) |
<select> with options fetched from referenced entity’s API |
Plain text |
Any type with enum |
<select> with enum values as options |
<Badge> (color-mapped) |
Form field filtering:
computed: true are excluded from forms (they are auto-generated)immutable: true are excluded from forms (they cannot be changed after creation)Reference field dropdowns:
The form component generates a useEffect hook that fetches all records from the referenced entity’s API on mount. The dropdown shows the display field (from the reference definition) for each option, with the id as the value.
# Entity contract field with reference
customer_id:
type: uuid
required: true
references:
entity: entity/helpdesk/customer
display: name
graph_edge: SUBMITTED_BY
This generates a <select> that:
customers.list(1000, 0)<option key={opt.id} value={opt.id}>{opt.name || opt.id}</option> for each customerThe Workflow contract defines the Kanban board:
# Workflow contract
spec:
initial: new
states:
new:
label: New
category: open
assigned:
label: Assigned
category: open
resolved:
label: Resolved
category: closed
terminal: true
transitions:
new: [assigned, closed]
assigned: [in_progress, closed]
in_progress: [resolved, closed]
The Kanban generator:
blue for open, yellow for hold, green for closed, gray for unknown) and a count badgecard_fields in the Page contract’s kanban viewVALID_TRANSITIONS map (generated from the workflow’s transitions) determines which drops are allowedring-1 ring-green-300)ring-2 ring-blue-400)opacity-75, no grab cursor)onTransition(id, newState) which hits the PUT /{id}/state endpointThe Route contract defines the API surface:
# Route contract
spec:
entity: entity/helpdesk/ticket
base_path: /tickets
endpoints:
- method: GET
path: /
- method: POST
path: /
- method: GET
path: /{id}
- method: PATCH
path: /{id}
- method: DELETE
path: /{id}
- method: PUT
path: /{id}/state
The API client generator maps each endpoint to a named function:
| Endpoint | Generated Function |
|---|---|
GET / |
list: (limit = 100, offset = 0) => _fetch(...) |
GET /{id} |
get: (id: string) => _fetch(...) |
POST / |
create: (data: any) => _fetch(...) |
PATCH /{id} |
update: (id: string, data: any) => _fetch(...) |
DELETE /{id} |
delete: (id: string) => _fetch(...) |
PUT /{id}/state |
transition: (id: string, state: string) => _fetch(...) |
The generated client uses NEXT_PUBLIC_API_URL (defaults to http://localhost:8000) and includes Content-Type: application/json on all requests.
// Generated api.ts
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export const tickets = {
list: (limit = 100, offset = 0) => _fetch(`/tickets/?limit=${limit}&offset=${offset}`),
get: (id: string) => _fetch(`/tickets/${id}`),
create: (data: any) => _fetch(`/tickets/`, { method: "POST", body: JSON.stringify(data) }),
update: (id: string, data: any) => _fetch(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (id: string) => _fetch(`/tickets/${id}`, { method: "DELETE" }),
transition: (id: string, state: string) => _fetch(`/tickets/${id}/state`, { method: "PUT", body: JSON.stringify({ state }) }),
};
Generated per entity. Features:
router.push)<Badge> components with color mappingGenerated per entity that has a state machine. Features:
Generated per entity. Features:
<select> with dynamic option loading<select> with static options<textarea>step attribute*)FormData API (no form library dependency)Generated per entity. Features:
<Badge> componentsGenerated once per domain. Features:
pathname.startsWith(item.href))The frontend is the 4th service in the generated Docker Compose stack:
# Generated docker-compose.yml includes:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.frontend
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://backend:8000
The Dockerfile.frontend uses a multi-stage build:
node:20-slim, installs dependencies, runs next buildnode:20-slim, copies standalone output, runs node server.jsThe next.config.js uses output: 'standalone' which produces a self-contained Node.js server without needing the full node_modules.
from pathlib import Path
from forge.ir.compiler import Compiler
from forge.targets.nextjs.generator import NextJSGenerator
ir = Compiler(contract_root=Path("domains/helpdesk")).compile()
gen = NextJSGenerator()
files = gen.generate(ir)
output = Path("runtime")
for f in files:
path = output / f.path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f.content)
print(f"Generated: {f.path}")
from forge.targets.nextjs.gen_scaffold import generate_scaffold
from forge.targets.nextjs.gen_api_client import generate_api_client
from forge.targets.nextjs.gen_components import generate_components
from forge.targets.nextjs.gen_pages import generate_pages
from forge.targets.nextjs.gen_layout import generate_layout
# Scaffold only
for f in generate_scaffold(ir):
print(f.path)
# API client only
api_file = generate_api_client(ir)
print(api_file.content)
# Components only (includes entity-specific + primitives)
for f in generate_components(ir):
print(f.path)
# Pages only (list, detail, create per entity)
for f in generate_pages(ir):
print(f.path)
# Layout + dashboard + Docker
for f in generate_layout(ir):
print(f.path)
The generator returns an empty list if there are no Page contracts:
gen = NextJSGenerator()
files = gen.generate(ir)
if not files:
print("No Page contracts found -- skipping frontend generation")
# Generate everything (backend + database + frontend + migrations)
specora generate --target all
# Generate only the frontend
specora generate --target nextjs
# After generation, install and run
cd runtime/frontend
npm install
npm run dev
# Frontend available at http://localhost:3000
Given these contracts:
domains/shop/entities/product.contract.yamldomains/shop/routes/products.contract.yamldomains/shop/pages/products.contract.yamlThe generator produces:
runtime/frontend/
package.json
next.config.js
tailwind.config.js
postcss.config.js
tsconfig.json
Dockerfile.frontend
.dockerignore
src/
lib/
utils.ts
api.ts
types.ts
components/
ui/
button.tsx
input.tsx
badge.tsx
card.tsx
select.tsx
table.tsx
ProductTable.tsx
ProductForm.tsx
ProductDetail.tsx
AppSidebar.tsx
app/
globals.css
layout.tsx
page.tsx # Dashboard
products/
page.tsx # List page
[id]/
page.tsx # Detail page
new/
page.tsx # Create page
If the product entity also has a workflow (state machine), a ProductKanban.tsx is generated and the list page includes a Table/Kanban toggle.