OpeNext CMS · Developer Docs

Theme Upload

Upload full site themes as Theme Engine ZIP packages from Dashboard → Themes. Uploading registers an inactive theme — Activate to render it on the public site.

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 -Force

Pre-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 page

Size 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 →