How theme upload works today
OpeNext CMS has a single dashboard upload path for full themes: Dashboard → Themes (/dashboard/theme-engine). Upload a Theme Engine .zip package containing theme.json, theme.ts, and React/TSX templates. The CMS validates the package, extracts it to themes/{slug}/, and registers it in MongoDB as inactive.
| Step | What happens |
| ---- | ------------ |
| 1. Upload ZIP | Validates theme.json + paths + theme.ts AST; extracts to themes/{slug}/ |
| 2. Activate | Sets active Theme Engine slug; public pages render via your TSX templates |
| 3. Preview | Optional live preview in the dashboard before activation |Upload alone never changes the live site. After upload, click Activate on the theme card. Requires Owner or Admin role.
- API: POST /api/theme-engine/themes/upload (multipart .zip)
- Duplicate slug returns 409 — choose a unique slug or remove the existing theme first
- Legacy upload routes (/api/themes/upload, /api/themes/import, /api/theme-system/*) are removed
- Do not place packages manually under public/ — always use the dashboard upload flow
Theme Builder vs Theme Engine upload
These are different tools for different jobs:
| Tool | Purpose | Upload? |
| ---- | ------- | ------- |
| Theme Engine (Dashboard → Themes) | Full React/TSX site themes | Yes — .zip upload |
| Theme Builder (Dashboard → Themes → Create / edit) | Token colors, typography, variants | No file upload — edit in UI |Use the Theme Builder to duplicate a system theme and tweak design tokens in the dashboard. Use Theme Engine upload when you ship a complete TSX theme package with custom templates, partials, and editable blocks.
Package structure
Every upload must include theme.json at the package root (or inside one wrapper folder). The CMS reads metadata first — missing or invalid JSON fails the upload.
my-theme/
├── theme.json Required — metadata only
├── theme.ts Required — registration entry
├── templates/
│ └── Index.tsx Required (index fallback)
├── partials/
│ ├── Header.tsx Optional
│ └── Footer.tsx Optional
├── blocks/ Required for editable homepage sections
│ └── Hero/
│ ├── Renderer.tsx
│ └── schema.ts
└── assets/
├── style.css Recommended
└── fonts/
└── brand.woff2 Optional- Zip the contents so theme.json is at the root, or zip one wrapper folder — both work
- Do not include node_modules, .env, or npm dependencies in the ZIP
- Use forward-slash relative paths; @/ aliases are not supported in theme files
- Only theme.ts may import @cms/theme for defineTheme registration
theme.json (metadata)
{
"name": "My Theme",
"slug": "my-theme",
"author": "Your Company",
"version": "1.0.0",
"description": "A production website theme.",
"compatibleCmsVersion": "^2.0.0",
"entry": "theme.ts",
"supportedFeatures": ["block-editor"],
"editableSections": [
{
"id": "hero",
"label": "Hero",
"blockType": "my-hero",
"defaults": { "title": "Welcome" }
}
]
}Either "entry": "theme.ts" or legacy manifest fields (templates.index, blocks[]) must resolve to a valid index template. New themes should use theme.ts registration.
theme.ts registration
import { defineTheme } from '@cms/theme';
export default defineTheme((theme) => {
theme.registerTemplate('index', './templates/Index');
theme.registerTemplate('home', './templates/Home');
theme.registerPartial('header', './partials/Header');
theme.registerPartial('footer', './partials/Footer');
theme.registerBlock({
type: 'my-hero',
rendererPath: './blocks/Hero/Renderer',
schemaPath: './blocks/Hero/schema',
label: 'Hero',
});
theme.registerStyle('./assets/style.css');
});At upload, theme.ts is validated (AST + path checks) but not executed. It runs at render time when the theme is active. Templates, blocks, and assets can also be auto-discovered from conventional folders.
Templates and rendering
templates/Index.tsx — every template must default-export a component:
export default function Index({ theme }: { theme: any }) {
const site = theme.site();
const blocks = theme.blocks();
return (
<main className="my-theme">
{theme.header()}
<h1>{site.name}</h1>
{blocks}
{theme.footer()}
</main>
);
}Template resolution order:
- Homepage: home → page → index
- Normal page: page → index
- Blog post: post → page → index
- index is always required as the final fallback
Theme component API available in templates and partials:
- theme.site() — site name, URL, description
- theme.page() — current page metadata
- theme.menu(key) / theme.navigation(key) — menu items
- theme.settings() — theme setting values
- theme.blocks() — rendered CMS or theme-declared blocks
- theme.asset(path) — URL for an uploaded theme asset
- theme.header() / theme.footer() — resolved partials or built-in fallback
Active theme assets are served at /theme-engine-assets/{slug}/…. Public pages render through the Theme Engine pipeline (themeManager.renderPage()).
Editable blocks
To make homepage sections editable in the block editor, register blocks in theme.ts and list them in editableSections. Each block folder needs a renderer and schema.
// blocks/Hero/schema.ts
const schema = {
fields: [
{ key: 'title', label: 'Title', type: 'text', default: 'Welcome' },
{ key: 'description', label: 'Description', type: 'textarea' },
{ key: 'image', label: 'Background', type: 'image' },
{ key: 'accent', label: 'Accent', type: 'color', default: '#2563eb' },
{ key: 'showButton', label: 'Show button', type: 'boolean', default: true },
{
key: 'alignment',
label: 'Alignment',
type: 'select',
default: 'left',
options: ['left', 'center']
}
]
};
export default schema;Supported schema field types: text, textarea, color, image, number, boolean, url, select, list. Templates must call theme.blocks() to render editable sections.
Upload from the dashboard
- 1. Log in as Owner or Admin
- 2. Open Dashboard → Themes
- 3. Click Upload and select your .zip file
- 4. If the slug already exists, confirm replace or pick a new slug in theme.json
- 5. Activate the theme when ready — public pages switch to your TSX templates
# PowerShell — create a upload-ready ZIP
Compress-Archive -Path .\my-theme\* -DestinationPath .\my-theme.zip -ForcePre-upload checklist:
- theme.json present with valid JSON, name, slug, and version
- theme.ts registers at least templates/Index
- templates/Index.tsx default-exports a React component
- No node_modules, .env, .php, .sh, or executables
- Paths use forward slashes, relative to theme root
Runtime loading sequence
Upload ZIP
→ Extract to storage/tmp (never directly into themes root)
→ Validate theme.json, paths, theme.ts AST, allowed file types
→ Move to themes/{slug}/
→ Register MongoDB ThemeEngineTheme row (inactive)
Activate
→ Set activeThemeSlug in ThemeEngineSettings
→ Public [slug] pages call themeManager.renderPage()
Render (request time)
→ Execute theme.ts → build registry → compile TSX → render pageSize and security limits
| Rule | Theme Engine ZIP |
| ---- | ---------------- |
| Max upload size | 60 MB |
| Max uncompressed | 50 MB |
| Max files | 2,000 |
| Max single file | 10 MB |
| Allowed | .tsx, .css, .js, fonts, images (.png, .jpg, .svg, .webp, .avif) |
| .json | Root theme.json only |
| .ts | theme.ts, blocks/**/schema.ts, editor/**, hooks/** only |
| Blocked | .html, .exe, .php, .sh, .bat, npm imports in theme files |Common errors
- 400 — invalid JSON, missing theme.json, bad paths, blocked file type, theme.ts validation failed
- 403 — user is not Owner or Admin
- 409 — duplicate theme slug (replace existing or change slug in theme.json)
- 413 — ZIP or uncompressed content exceeds size limits
- Blank public page — verify templates/Index.tsx default-exports a component
- Editable sections missing — register blocks in theme.ts and call theme.blocks() in templates
- Assets 404 — use theme.asset('assets/...') so URLs resolve through /theme-engine-assets/your-slug/
Related guides
For in-dashboard token editing (no ZIP upload), see the Theme Builder. For dashboard plugins, see Plugin Development.
Full package specification in the CMS repository: docs/theme-upload-format.md on GitHub →