The modern, open-source CMS for the Next.js ecosystem.
WordPress taught the world that anyone can publish on the web. NextPress carries that mission forward with a modern foundation: type-safe TypeScript, server-rendered React, structured block content, and an API-first architecture that works for both traditional websites and headless frontends.
NextPress is a content management system that gives you the flexibility of WordPress — custom content types, a plugin ecosystem, swappable themes, a block-based editor, media management, editorial workflows, SEO, comments, menus, and multi-site — built entirely on Next.js, TypeScript, PostgreSQL, and Prisma.
It is designed for developers who want to build content-driven websites and applications without sacrificing type safety, performance, or the ability to extend every part of the system.
WordPress is PHP, relies on a 20-year-old architecture, has no type safety, mixes rendering with data access, and doesn't support modern deployment targets (serverless, edge, containers) without significant friction. NextPress solves these problems while preserving what WordPress got right: the content model, the plugin/theme ecosystem pattern, and the editorial experience.
Headless CMSs separate the backend from the frontend, which adds latency, deployment complexity, and a disconnect between content editing and content rendering. NextPress keeps them in one repository — the admin panel and the public site share the same process, the same types, and the same cache. Publish a post and the public page updates in under a second, not after a webhook round-trip.
NextPress is in active development. The architecture is complete and the core systems are implemented:
| Metric | Value |
|---|---|
| TypeScript files | 244 |
| Lines of code | 18,752 |
| Prisma models | 28 |
| tRPC routers | 11 |
| Service modules | 11 |
| Block types | 6 |
| Test files | 13 |
| Permissions | 28 |
| Hook events | 16 |
The next step is wiring the monorepo dependencies (pnpm install), running the first migration, and booting the dev server. See Getting Started.
- Architecture
- Tech Stack
- Repository Structure
- Data Model
- Key Interfaces
- Route Map
- Authentication & Authorization
- Content System
- Block Editor
- Plugin System
- Theme System
- Editorial Workflow
- Media Library
- Search
- SEO
- Comments
- Settings & Menus
- API Design
- Caching & Revalidation
- Security Model
- Testing Strategy
- Roadmap
- Getting Started
┌─────────────────────────────────────────────────────────────────┐
│ NEXTPRESS MONOREPO │
│ │
│ ┌─────────────────────┐ ┌────────────────────────────┐ │
│ │ PUBLIC SITE (SSR) │ │ ADMIN PANEL (CSR/SSR) │ │
│ │ app/(site)/... │ │ app/(admin)/... │ │
│ └──────────┬───────────┘ └─────────────┬──────────────┘ │
│ │ │ │
│ ┌──────────┴───────────────────────────────┴──────────────┐ │
│ │ CORE ENGINE │ │
│ │ Content · Fields · Taxonomy · Media · Auth · Hooks │ │
│ │ Themes · Plugins · SEO · Search · Comments · Settings │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ┌──────────────────────────┴──────────────────────────────┐ │
│ │ API LAYER │ │
│ │ tRPC (11 routers) · REST /api/v1/* · Webhooks │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ┌──────────────────────────┴──────────────────────────────┐ │
│ │ DATA LAYER │ │
│ │ Prisma (PostgreSQL) · Cache (unstable_cache) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ PLUGINS │ │ THEMES │ │ BLOCK EDITOR │ │
│ │ plugins/* │ │ themes/* │ │ packages/editor │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key design decisions:
- Route groups separate admin
(admin), public(site), and auth(auth)— different layouts, same process. - packages/core is framework-agnostic (no React, no Next.js imports). Testable in isolation.
- packages/api (tRPC) sits between the UI and core. Pages never import Prisma directly.
- packages/blocks (render components) are server-safe. packages/editor (edit components) are client-only. They share types but never import each other.
- Plugins interact with the CMS only through
PluginContext— a controlled API surface with source-tracked registrations. - Themes provide layout + templates + block overrides. Template resolution follows the WordPress hierarchy.
Dependency direction (never violated):
apps/web → packages/api → packages/core → packages/db
→ packages/blocks
apps/web → packages/editor (admin only)
apps/web → packages/ui (admin only)
plugins → packages/core (via PluginContext)
themes → packages/blocks (via overrideRenderComponent)
| Layer | Technology | Why |
|---|---|---|
| Framework | Next.js 14+ (App Router) | SSR, RSC, ISR, streaming, middleware |
| Language | TypeScript (strict) | End-to-end type safety |
| Database | PostgreSQL | JSONB for blocks/meta, tsvector for search, GIN indexes |
| ORM | Prisma | Type generation, migrations, query builder |
| Auth | Auth.js (NextAuth v5) | JWT sessions, OAuth providers, Prisma adapter |
| Internal API | tRPC v11 | Type-safe client-server, superjson, React Query |
| External API | REST /api/v1/* | Versioned, CORS, JSON envelope |
| Validation | Zod | Runtime validation, schema composition, type inference |
| UI | Tailwind CSS + custom components | Admin design system in packages/ui |
| Monorepo | pnpm workspaces + Turborepo | Workspace packages, build caching |
nextpress/
├── apps/web/ # Next.js application
│ ├── app/
│ │ ├── (admin)/admin/ # Admin panel (auth-gated)
│ │ ├── (site)/ # Public site (SSR, cached)
│ │ ├── (auth)/ # Login, register
│ │ └── api/ # REST, tRPC, upload, cron, webhooks
│ ├── components/admin/ # Admin React components (client)
│ ├── components/site/ # Public site components (server)
│ ├── hooks/ # Client React hooks
│ ├── lib/ # Next.js-specific glue
│ │ ├── auth/ # Auth.js config, session, guards
│ │ ├── trpc/ # tRPC client, server caller, provider
│ │ ├── cache/ # Cache tags, revalidation, cached queries
│ │ ├── permissions/ # Permission check/assert helpers
│ │ ├── site/ # Multi-tenant site resolution
│ │ ├── seo/ # Metadata, structured data, OG image
│ │ └── api/ # REST API helpers (CORS, envelope)
│ └── middleware.ts # Auth gate, route protection
│
├── packages/
│ ├── core/ # CMS business logic (NO React)
│ │ └── src/
│ │ ├── auth/ # Permission engine, roles, types
│ │ ├── content/ # Content CRUD, queries, review workflow
│ │ ├── content-type/ # Content type registration
│ │ ├── fields/ # Field definitions, dynamic Zod validator
│ │ ├── revision/ # Revision snapshots, diff engine
│ │ ├── taxonomy/ # Taxonomy service (stub)
│ │ ├── media/ # Upload, storage (local/S3), image processing
│ │ ├── comment/ # Threaded comments, moderation
│ │ ├── menu/ # Navigation menus, URL resolution
│ │ ├── settings/ # Key-value settings, plugin groups
│ │ ├── hooks/ # Hook engine (actions + filters)
│ │ ├── plugin/ # Plugin manager, context, lifecycle
│ │ ├── theme/ # Theme manager, template resolver
│ │ ├── seo/ # SEO service, sitemap, structured data
│ │ ├── search/ # PostgreSQL FTS, provider interface
│ │ ├── scheduling/ # Cron-based scheduled publishing
│ │ ├── webhooks/ # Webhook delivery, HMAC signatures
│ │ ├── validation/ # Shared Zod schemas, slug utilities
│ │ └── errors/ # CMS error hierarchy
│ │
│ ├── db/ # Prisma schema, client, seeds
│ ├── api/ # tRPC routers (11 implemented)
│ ├── blocks/ # Block registry + render components (SSR-safe)
│ ├── editor/ # Block editor + edit components (client-only)
│ └── ui/ # Admin design system components
│
├── plugins/ # Plugin directory
│ ├── _template/ # Plugin scaffold
│ ├── seo-toolkit/ # SEO fields, meta tags, sitemap
│ └── contact-form/ # Custom block + content type + API
│
├── themes/ # Theme directory
│ ├── _template/ # Theme scaffold
│ └── default/ # Default theme (6 templates)
│
└── tooling/ # Shared ESLint, TypeScript, Prettier configs
28 Prisma models, 891 lines. PostgreSQL with JSONB, tsvector, and GIN indexes.
AUTH CONTENT RELATIONS
──── ─────── ─────────
Account ContentEntry ContentTerm (M:N)
Session ContentType ContentMedia (M:N + role)
VerificationToken FieldDefinition UserSite (M:N + role)
User FieldValue RolePermission (M:N)
UserMeta Revision
Role BlockTemplate
Permission
TAXONOMY MEDIA NAVIGATION CONFIG
──────── ───── ────────── ──────
Taxonomy MediaAsset Menu Setting
Term MenuItem PluginInstall
ThemeInstall
SOCIAL SEO Redirect
────── ───
Comment (via fields + hooks)
MULTI-TENANT
────────────
Site (every scoped model carries siteId)
Key schema decisions:
- Unified ContentEntry table with
contentTypeIddiscriminator. Custom post types are rows in ContentType, not new tables. - EAV with JSON values for custom fields (FieldDefinition + FieldValue). Queryable via JSON operators + GIN index. No schema changes for new fields.
- JSONB blocks for content (not serialized HTML). Structured, queryable, re-renderable by different themes.
- Row-level multi-tenancy via
siteIdon all scoped models. Single-tenant deployments use one Site row.
interface ContentEntryDto {
id: string;
siteId: string;
contentType: { id: string; slug: string; nameSingular: string };
status: "DRAFT" | "PENDING_REVIEW" | "PUBLISHED" | "SCHEDULED" | "PRIVATE" | "ARCHIVED" | "TRASH";
title: string;
slug: string;
excerpt: string | null;
blocks: BlockData[];
author: { id: string; name: string | null; displayName: string | null; image: string | null };
fields: Record<string, unknown>;
terms: Array<{ id: string; name: string; slug: string; taxonomy: { slug: string } }>;
featuredImage: { url: string; alt: string | null; width: number | null; height: number | null } | null;
publishedAt: Date | null;
scheduledAt: Date | null;
createdAt: Date;
updatedAt: Date;
revisionCount: number;
}interface BlockData {
id: string; // unique instance ID
type: string; // "core/paragraph", "plugin/testimonial"
attributes: Record<string, unknown>; // type-specific data + __version
innerBlocks: BlockData[]; // nested blocks (columns, groups)
}interface BlockDefinition<TSchema extends z.ZodObject> {
type: string; // namespaced: "core/paragraph"
title: string;
icon: string;
category: BlockCategory;
attributesSchema: TSchema; // Zod schema — single source of truth
defaultAttributes: z.infer<TSchema>;
version: number; // increment on breaking changes
migrate?: (old: Record<string, unknown>, fromVersion: number) => Record<string, unknown>;
allowsInnerBlocks: boolean;
source: string; // "core", theme slug, or plugin slug
renderComponent: ComponentType<BlockRenderProps> | null; // server-safe
}interface AuthContext {
user: SessionUser; // id, email, name, displayName, image
siteId: string; // current site (multi-tenant)
role: RoleSlug; // resolved from UserSite join
permissions: Set<PermissionSlug>; // 28 granular permissions
}interface PluginDefinition {
slug: string;
onActivate: (ctx: PluginContext) => void | Promise<void>;
onDeactivate?: (ctx: PluginContext) => void | Promise<void>;
onUninstall?: (ctx: PluginContext) => void | Promise<void>;
}interface ThemeManifest {
name: string;
slug: string;
version: string;
supports: { menuLocations: string[]; customColors: boolean; darkMode: boolean };
settings: Record<string, unknown>; // JSON Schema for customization
templates: string[]; // provided template names
templateChoices: Array<{ // per-entry template options
slug: string; name: string; contentTypes: string[];
}>;
}| Route | Permission | Purpose |
|---|---|---|
/admin |
read |
Dashboard (stats, recent entries) |
/admin/posts |
read |
Post list (filter, search, paginate) |
/admin/posts/new |
create_content |
Create post (block editor) |
/admin/posts/[id]/edit |
edit_own_content |
Edit post |
/admin/[contentType] |
read |
Dynamic content type list |
/admin/media |
upload_media |
Media library |
/admin/comments |
moderate_comments |
Comment moderation |
/admin/users |
list_users |
User management |
/admin/menus |
manage_menus |
Menu builder |
/admin/appearance/themes |
switch_themes |
Theme gallery |
/admin/plugins |
manage_plugins |
Plugin manager |
/admin/content-types |
manage_content_types |
Content type builder |
/admin/taxonomies |
manage_taxonomies |
Taxonomy manager |
/admin/settings/* |
manage_settings |
Settings (general, reading, discussion, permalinks) |
/admin/profile |
read |
Own profile |
| Route | Rendering | Caching |
|---|---|---|
/ |
Homepage template, latest entries | unstable_cache + tag homepage:{siteId} |
/[...slug] |
Template resolver: single entry or taxonomy archive | unstable_cache + tag content:{id} |
/search?q=... |
PostgreSQL FTS, search template | Dynamic (no cache) |
/sitemap.xml |
XML sitemap generator | s-maxage=3600 |
/feed.xml |
RSS 2.0 feed | s-maxage=3600 |
| Route | Auth | Purpose |
|---|---|---|
/api/trpc/* |
Session cookie | tRPC (11 routers, admin internal) |
/api/v1/content/{type} |
Optional | REST: list/create content |
/api/v1/content/{type}/{id} |
Optional | REST: get/update/delete content |
/api/v1/media |
Required | REST: list media |
/api/v1/taxonomies |
None | REST: list taxonomies + terms |
/api/v1/menus/{location} |
None | REST: get menu |
/api/v1/comments |
None/Optional | REST: list/submit comments |
/api/v1/search |
None | REST: full-text search |
/api/v1/settings |
None | REST: public settings |
/api/upload |
Required | Multipart file upload |
/api/webhooks |
Signature | Incoming webhook receiver |
/api/cron/publish |
CRON_SECRET | Scheduled publishing |
/api/revalidate |
REVALIDATION_SECRET | Cache invalidation |
4-layer defense:
| Layer | Where | What | On Failure |
|---|---|---|---|
| Middleware | Edge | JWT exists for /admin/* |
Redirect /login |
| Layout guard | Server component | getAuthContext() + canAccessAdmin() |
Redirect /login |
| Page guard | Server component | requirePermission("manage_settings") |
Redirect /admin?error=forbidden |
| Mutation guard | tRPC / service | assertCan(auth, "publish_content") |
Throw FORBIDDEN |
Session strategy: JWT (stateless). The JWT contains SessionUser (5 fields). Role + permissions resolved per-request from UserSite → Role → RolePermission → Permission. This means role changes take effect immediately (no JWT expiry wait) and users can have different roles on different sites.
6 built-in roles:
| Role | Key Permissions |
|---|---|
| super_admin | Bypasses all checks (hardcoded, not in permission table) |
| admin | All 28 permissions for one site |
| editor | All content + comments + menus, no users/plugins/settings |
| author | Own content + publish + media |
| contributor | Own content, cannot publish |
| subscriber | Read + edit profile only |
Ownership-aware: edit_own_content and edit_others_content are separate permissions. The can() function checks ownership when a ResourceContext is provided.
DRAFT ────────→ PENDING_REVIEW | PUBLISHED | SCHEDULED | PRIVATE | TRASH
PENDING_REVIEW → DRAFT | PUBLISHED | SCHEDULED | PRIVATE | TRASH
PUBLISHED ────→ DRAFT | PRIVATE | ARCHIVED | TRASH
SCHEDULED ────→ DRAFT | PUBLISHED | TRASH
PRIVATE ──────→ DRAFT | PUBLISHED | TRASH
ARCHIVED ─────→ DRAFT | PUBLISHED | TRASH
TRASH ────────→ DRAFT (only)
Transitions validated at the service layer. publish_content permission required for PUBLISHED and SCHEDULED.
15 field types: TEXT, TEXTAREA, RICHTEXT, NUMBER, BOOLEAN, DATE, DATETIME, SELECT, MULTISELECT, MEDIA, RELATION, COLOR, URL, EMAIL, JSON.
Each FieldDefinition declares: key, type, validation rules (Zod), options (for SELECT), required flag, default value, group (for admin UI).
field-validator.ts builds a Zod schema at runtime from FieldDefinition rows, then validates field values on every create/update.
Every explicit save creates an immutable Revision snapshot (title, blocks, excerpt, fieldValues as JSON). Autosave (every 30s) writes to the entry directly without creating a revision. Restore copies revision data back and creates a new revision (history is never lost). Default retention: 25 revisions per entry.
Two packages, one contract:
| Package | Runtime | Purpose |
|---|---|---|
packages/blocks |
Server + Client | BlockDefinition types, registry, <BlockRenderer> (SSR), 6 render components |
packages/editor |
Client only | <NextPressEditor>, EditorProvider, 6 edit components, serialization, undo/redo |
6 implemented blocks: paragraph, heading, image, quote, button/CTA, columns (with nesting).
Block lifecycle:
- Block defined with Zod attribute schema + version number
- Registered in global registry (side-effect import)
- Editor creates/edits BlockData via the edit component
- Serialization validates + migrates before save
- Renderer validates + migrates + renders via the render component
- Themes override render components via
overrideRenderComponent() - Plugins register new block types via
ctx.blocks.register()
Security: DOMPurify sanitizes all rich text output. Unknown block types skip silently in production. Zod validates attributes at every checkpoint (save, load, render).
Lifecycle: Discover (scan plugins/ for plugin.json) → Load (import index.ts) → Activate (call onActivate(ctx)) → Deactivate (remove hooks by source) → Uninstall (call onUninstall(ctx)).
PluginContext API surface:
| Method | What It Does |
|---|---|
ctx.hooks.addAction(hook, callback) |
Register lifecycle hook (source-tracked) |
ctx.hooks.addFilter(hook, callback) |
Register data transformation filter |
ctx.content.registerType(input) |
Create a custom content type |
ctx.content.registerFields(type, fields) |
Add custom fields to a content type |
ctx.blocks.register(definition) |
Register a custom block type |
ctx.admin.registerPage(item) |
Add an admin navigation item |
ctx.admin.registerSidebarPanel(panel) |
Add editor sidebar panel |
ctx.api.registerRoute(method, path, handler) |
Register a custom API endpoint |
ctx.settings.get() / update(values) |
Read/write plugin settings |
ctx.taxonomies.register(input) |
Create a custom taxonomy |
16 hook events: content lifecycle (6), rendering filters (3), admin UI (2), user (2), comments (2), media (1).
Dependency resolution: Topological sort (Kahn's algorithm) ensures plugins boot in dependency order. Missing dependencies prevent activation.
Safety boundary: Plugins access CMS capabilities only through PluginContext. Settings routed through settingsService (not raw Prisma). All registrations tagged with plugin slug for clean deactivation.
File conventions:
themes/{slug}/
├── theme.json # Manifest + settings schema
├── layout.tsx # Root layout (header, footer, shell)
├── templates/ # WordPress-style template hierarchy
│ ├── index.tsx # Required fallback
│ ├── single.tsx # Any single entry
│ ├── single-{type}.tsx # Single entry of specific type
│ ├── page.tsx # Hierarchical content
│ ├── archive.tsx # Content listing
│ ├── home.tsx # Homepage
│ ├── search.tsx # Search results
│ ├── taxonomy.tsx # Taxonomy archive
│ └── 404.tsx # Not found
├── blocks/ # Block render overrides
│ └── paragraph.tsx # Override core/paragraph
├── components/ # Theme-specific components
└── styles/theme.css # Theme-specific CSS
Template resolution (most specific → least specific):
Single post "hello-world" with template "full-width":
full-width → single-post-hello-world → single-post → single → index
Category "tech" archive:
taxonomy-category-tech → taxonomy-category → taxonomy → archive → index
Homepage:
front-page → home → index
Per-entry template override: editors select a template in the sidebar (defined in theme.json templateChoices). The resolver checks it first.
CONTRIBUTOR EDITOR/ADMIN
│ │
├─ Writes content (DRAFT) │
├─ Autosave every 30s (no revision) │
├─ Clicks "Save Draft" (revision) │
├─ Clicks "Submit for Review" │
│ → status: PENDING_REVIEW │
│ → hook: content:status_change │
│ ├─ Reviews content
│ ├─ "Approve & Publish" → PUBLISHED
│ └─ "Request Changes" → DRAFT + note
│ shown as amber banner in editor
├─ Fixes, re-submits │
└─ ───────────────────────────────── └─ Approves → published
Scheduled publishing: Set scheduledAt date → status becomes SCHEDULED → cron endpoint (POST /api/cron/publish) checks every minute → transitions to PUBLISHED with lifecycle hooks.
Upload flow: Multipart POST → validate (MIME allowlist, 50MB cap) → store original (local or S3) → if image: extract dimensions, generate 5 WebP variants (thumbnail, small, medium, large, og) → create MediaAsset record.
Storage abstraction:
interface StorageProvider {
put(key: string, data: Buffer, contentType: string): Promise<StoredFile>;
delete(key: string): Promise<void>;
exists(key: string): Promise<boolean>;
getUrl(key: string): string;
}
// Implementations: LocalStorage (dev), S3Storage (prod — works with S3, R2, MinIO)Security: SVG excluded from upload (XSS vector). Filenames sanitized. Storage keys include siteId (no cross-tenant access). Buffer size validated against declared size.
MVP: PostgreSQL full-text search. tsvector column on ContentEntry, auto-updated by trigger. GIN index for fast @@ matching. Weighted ranking: title (A) > excerpt (B). ts_headline for highlighted snippets.
Scalable path: SearchProvider interface abstracts the backend. Set SEARCH_PROVIDER=meilisearch to delegate to an external engine. searchService.extractTextFromBlocks() extracts plain text from JSONB blocks for indexing.
Metadata resolution: _seo_title field → entry.title → site name. _seo_description → entry.excerpt → site tagline. Runs through render:meta_tags hook filter (plugin-extensible).
Outputs: Next.js generateMetadata() on all public routes, JSON-LD structured data (Article, WebPage, WebSite, BreadcrumbList), XML sitemap with pagination, RSS 2.0 feed, OG image generation via next/og.
Redirect model: Redirect table (fromPath, toPath, statusCode, isRegex, hitCount).
Threaded (max depth 3). Dual author identity: registered users (via authorId) or guests (name + email). 4 moderation statuses: PENDING → APPROVED, SPAM, TRASH. Auto-approve for moderators. comment:submitted hook for spam detection plugins. DOMPurify sanitizes body (7 allowed tags). javascript: URLs blocked.
Settings: Key-value in Setting table, grouped by domain (general, reading, discussion, permalinks). Plugins register additional groups via settingsService.registerGroup(). Dynamic <SettingsForm> renders fields from group definitions.
Menus: Nested items with 3 link types (custom URL, content entry, taxonomy term). Content/taxonomy URLs resolved at read time (batched queries, not N+1). One menu per theme location. <NavMenu> server component with cached queries.
Internal (admin): tRPC with 11 routers. Type-safe, batched, superjson transformer. Procedures: publicProcedure, authedProcedure, permissionProcedure("slug").
External (public): REST /api/v1/*. Versioned, CORS (explicit origin allowlist), standard JSON envelope { data, meta? } or { error: { code, message } }.
Webhooks (outgoing): 9 event types, HMAC-SHA256 signatures, 3 retries with exponential backoff. Subscriptions stored in Settings.
Webhooks (incoming): /api/webhooks with mandatory source-specific secrets and timing-safe signature comparison.
PUBLIC PAGE REQUEST
└─ getCachedEntry(siteId, slug)
├─ unstable_cache with tags: [content:{id}, content-list:_all:{siteId}]
└─ revalidate: 300 (5 min fallback TTL)
ADMIN MUTATION (publish/update/trash/delete)
└─ tRPC router calls revalidateForEntry(entry)
├─ revalidateTag("content:{id}")
├─ revalidateTag("content-list:{type}:{siteId}")
├─ revalidateTag("homepage:{siteId}")
├─ revalidateTag("sitemap:{siteId}")
└─ revalidateTag("taxonomy:{termSlug}:{siteId}") × N terms
TIME TO LIVE: ~0.5s from publish to fresh public page
10 tag patterns. Revalidation wired via callback injection (API layer doesn't import from app layer).
Hardened (9 vulnerabilities found and fixed during audit):
| Protection | Implementation |
|---|---|
| Auth | JWT sessions, bcrypt (12 rounds), timing-safe credential check |
| RBAC | 28 permissions, 6 roles, ownership-aware, 4-layer enforcement |
| XSS | DOMPurify on all HTML output, javascript: URL blocking, SVG upload rejected |
| CORS | Explicit origin allowlist (ALLOWED_ORIGINS env var), not wildcard |
| CSRF | tRPC uses POST + JSON (unforgeable). State-changing endpoints are POST only. |
| File upload | MIME allowlist (no SVG), 50MB cap, filename sanitization, site-scoped storage keys |
| Webhooks | Mandatory secrets, HMAC-SHA256, timing-safe comparison |
| Cron | CRON_SECRET required, POST only |
| Revalidation | REVALIDATION_SECRET required, disabled when unset |
| Multi-tenant | siteId on every scoped query, comment cascade scoped to site |
| Security headers | X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin |
╱╲
╱E2E╲ 3 Playwright specs (auth, content, public site)
╱──────╲
╱API Tests╲ 1 tRPC caller test file
╱────────────╲
╱ Integration ╲ 2 test files (content service + plugin lifecycle)
╱────────────────╲
╱ Unit Tests ╲ 7 test files (permissions, slugs, fields, status,
╱────────────────────╲ revisions, templates, blocks)
Tools: Vitest (unit + integration), Playwright (E2E). CI: GitHub Actions — 4 parallel jobs (lint/typecheck, unit tests, integration tests with PostgreSQL, E2E with built app).
| Week | Focus | Deliverable |
|---|---|---|
| 1 | Boot | pnpm dev works, login, dashboard |
| 2 | Content CRUD | Create/edit/publish/trash posts via admin |
| 3 | Public site | Theme renders published content with SEO |
| 4 | Block editor | Paragraph, heading, image, quote blocks |
| 5 | Media + content types | Upload images, create custom types + fields |
| 6 | Taxonomy + comments + search | Categories, tags, threaded comments, FTS |
| 7 | Caching + polish | unstable_cache, revalidation, responsive admin |
| 8 | Editorial workflow | Scheduling, review/approve, revision history |
| 9 | Plugins + themes + settings | Activate SEO toolkit, menu builder, site settings |
| 10 | Test + secure + deploy | Tests pass, security checklist, production deploy |
Taxonomy CRUD UI, 15+ blocks, slash commands, dnd-kit drag-drop, block patterns, theme customizer, widget areas, custom roles UI, multi-site admin, redirects middleware, application passwords, bulk actions.
Full site editing, collaborative editing, child themes, GraphQL API, plugin marketplace, image editor, import/export (WXR), oEmbed.
- Node.js 20+
- pnpm 9+
- PostgreSQL 16+
- Docker (optional, for local PostgreSQL)
# Clone and install
git clone <repo-url> nextpress
cd nextpress
pnpm install
# Start PostgreSQL (or use docker-compose)
docker compose up -d
# Configure environment
cp .env.example .env
# Edit .env: set DATABASE_URL, NEXTAUTH_SECRET, ADMIN_PASSWORD
# Run migrations and seed
pnpm --filter @nextpress/db prisma migrate dev
pnpm --filter @nextpress/db prisma db seed
# Start development server
pnpm devDATABASE_URL=postgresql://user:pass@localhost:5432/nextpress
NEXTAUTH_SECRET=<32+ random bytes, hex>
NEXTAUTH_URL=http://localhost:3000
ADMIN_PASSWORD=<12+ chars>
CRON_SECRET=<32+ random bytes>
REVALIDATION_SECRET=<32+ random bytes>ALLOWED_ORIGINS=https://example.com
STORAGE_PROVIDER=s3
S3_BUCKET=nextpress-media
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_URL=https://cdn.example.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...MIT