NodePress CMS
A self-hosted headless CMS. Define your content structure, fill it with data through the admin panel, then consume it via REST API, GraphQL, or real-time WebSocket from any website, app, or platform.
Installation — Step by Step
Follow these steps in order. Each step takes only a few minutes. No prior coding experience required.
Already have some tools installed?
Run these commands in your terminal to check. If you see a version number, you can skip that step.
Install Node.js
node -v — if it shows v18 or higher, skip to Step 2.
Node.js is the engine that runs NodePress. Download and install version 18 or newer.
Download Node.js from nodejs.orgAfter installing, verify: node -v should print something like v22.0.0
Install Git
git --version — if it shows a version number, skip to Step 3.
Git is used to download the NodePress source code. You only need to install it — no need to learn how to use it.
Download Git from git-scm.comOn Windows: click Next through all the options — the defaults are fine.
Install PostgreSQL
docker-compose up -d inside your project folder after Step 4.
postgres user — you'll need it in Step 5. Then skip to Step 4.
PostgreSQL is the database where all your content is stored. Think of it as the filing cabinet behind the scenes.
Download PostgreSQL from postgresql.org⚠ Important during installation:
- When asked to set a password for the postgres user, write it down — you will need it in Step 5.
- Leave the port as 5432 (the default).
- PostgreSQL will run automatically in the background after installation.
Create your NodePress project
Open a terminal, navigate to the folder where you want your project, and run:
Replace my-project with your project name. This downloads NodePress, generates secret keys, and installs all dependencies. Takes 2–5 minutes.
Using Docker? Add the --docker flag — npx create-nodepress-app my-project --docker — to include docker-compose.yml plus the nginx/monitoring configs and docker:* scripts. Without it, the project is set up for local PostgreSQL.
Want error tracking? Add the --sentry flag to include Sentry (the @sentry/* packages, ~100 MB). It is off by default — without it the scaffold is lighter and ships no error-tracking code. Set SENTRY_DSN / NEXT_PUBLIC_SENTRY_DSN to activate.
Connect NodePress to your database
docker-compose up -d in your project folder instead — Docker manages the database password automatically.
The CLI generates a random database password, but NodePress needs to connect to your PostgreSQL using the password you set in Step 3.
Open my-project/backend/.env in any text editor (Notepad is fine) and find this line:
Replace RANDOM_PASSWORD with the password you set when installing PostgreSQL:
What is DATABASE_URL?
It's the address NodePress uses to find and log into your database. postgres is the username, the part after : is your password, localhost:5432 is where the database lives on your computer, and YOUR_NODEPRESS_DATABASE is the database name — you can name it anything you like.
DATABASE_URL="postgresql://postgres@localhost:5432/YOUR_NODEPRESS_DATABASE"
Create the database tables
Run this from your project root. It creates all the tables NodePress needs. You only run this once.
Using a cloud database (Neon, Supabase, Railway)? Use migrate deploy instead: cd backend && npx prisma migrate deploy
Start the dev server
Run this from your project root — it starts both backend and frontend together in one terminal:
Backend API → http://localhost:3000
Admin panel → http://localhost:5173
Need to run them separately? Use npm run dev:backend and npm run dev:frontend in two terminal windows.
Root scripts (shortcut)
The root package.json has convenience scripts so you can run everything from the project root without cd-ing into subdirectories. The docker:* scripts are only generated when you scaffold with --docker:
| Script | What it does |
|---|---|
| npm run dev | Start both backend and frontend together in one terminal |
| npm run dev:backend | Start backend dev server only (port 3000) |
| npm run dev:frontend | Start frontend dev server only (port 5173) |
| npm run build | Build both backend and frontend for production |
| npm run migrate | Run Prisma migrations (prisma migrate dev) |
| npm run studio | Open Prisma Studio — visual database browser |
| npm run install:all | Install all dependencies (backend + frontend) — alias for npm install via npm workspaces |
| npm run docker:dev | Start Docker dev stack (docker-compose up) |
| npm run docker:prod | Start Docker production stack with build |
| npm run docker:down | Stop all Docker containers |
Create your admin account
Open your browser and go to http://localhost:5173. You will be taken to the setup page automatically. Enter your site name, email, and a password.
🎉 You're done!
NodePress is running. You can now create content types, add entries, upload media, and start using the API. The setup page only appears once — it's disabled permanently after the first account is created.
Quick Start
Create a content type
Go to Content Types → New. Give it a name like blog and add fields: title (text), body (richtext), published (boolean).
Add an entry
Go to Entries → blog → New Entry. Fill in the fields. A URL-friendly slug is auto-generated from the title.
Fetch via API
Content Types
Content types define the shape of your data. Each content type has a name and a schema — a list of fields with types and options. Think of them as database tables with a visual builder.
Naming
The name you type is kept as the display name (shown in the admin); the API key is derived in snake_case. Blog Posts → blog_posts
Field label vs key
What you type is kept as the field's label (shown on forms & list columns); the API key is derived in snake_case — Article Footer → article_footer.
API
Creating a type instantly generates GET /api/{type} and GET /api/{type}/{slug}.
auth, media, entries, content-types, uploads — these are blocked to avoid route conflicts.
Field Types
| Type | Description | JSON value |
|---|---|---|
| text | Short single-line text. Good for titles, names, labels. | "My Blog Post" |
| textarea | Multi-line plain text. Good for short descriptions. | "A short summary..." |
| richtext | HTML from WYSIWYG editor. Supports headings, images, links. | "<p>Hello</p>" |
| number | Integer or decimal number. | 42 |
| boolean | True/false toggle. Good for published, featured flags. | true |
| select | One value from a predefined list of choices. | "tech" |
| image | A URL string pointing to an image (from Media Library or external). | "/uploads/photo.jpg" |
| color | Hex color value picked from a color-swatch widget. | "#ff6b35" |
| date | Calendar date (no time). Stored as an ISO 8601 date string. | "2024-12-25" |
| datetime | Full timestamp with time. Stored as an ISO 8601 datetime string. | "2024-12-25T10:30:00.000Z" |
| json | Arbitrary JSON object or array. Edited via a monospace textarea with live parse. | {"key":"value","tags":["a","b"]} |
| repeater | A list of items, each sharing the same sub-fields. | [{"name":"Alice"}] |
| flexible | A list of blocks where each block can be a different layout. | [{"_layout":"hero"}] |
| group | A single nested object with fixed sub-fields. Good for SEO metadata, address, social links. | {"title":"My Post"} |
| relation | Link to one or many entries in another content type. Stored as publicId UUID(s). Use ?populate= to inline the related data. | "uuid-v4" or ["uuid1","uuid2"] |
Repeater — example schema & output
Define sub-fields once, add unlimited rows in the editor. Each item shares the same structure.
// Schema definition
{
"name": "gallery",
"type": "repeater",
"subFields": [
{ "name": "image", "type": "image", "required": true },
{ "name": "caption", "type": "text" }
]
}
// API output — an array of objects
"gallery": [
{ "image": "/uploads/photo1.jpg", "caption": "First photo" },
{ "image": "/uploads/photo2.jpg", "caption": "Second photo" }
]
Flexible — example schema & output
Each item in the list can be a different layout — perfect for page builders. The _layout key tells you which block type it is.
// Schema definition
{
"name": "sections",
"type": "flexible",
"layouts": [
{
"name": "hero",
"label": "Hero Banner",
"fields": [{ "name": "heading", "type": "text" }]
},
{
"name": "text_block",
"label": "Text Block",
"fields": [{ "name": "body", "type": "richtext" }]
}
]
}
// API output — _layout tells you which block type it is
"sections": [
{ "_layout": "hero", "heading": "Welcome to NodePress" },
{ "_layout": "text_block", "body": "<p>Some content here</p>" }
]
Group — example schema & output
A fixed set of sub-fields stored as a single nested object. Unlike repeater, there is no list — just one object. Perfect for SEO metadata, address blocks, or social links.
// Schema definition
{
"name": "seo",
"type": "group",
"subFields": [
{ "name": "title", "type": "text" },
{ "name": "description", "type": "textarea" },
{ "name": "og_image", "type": "image" }
]
}
// API output — a single nested object, not an array
"seo": {
"title": "My Post",
"description": "A short summary of my post.",
"og_image": "/uploads/og-cover.jpg"
}
Relation — example schema & output
Links entries across content types using their publicId UUID. Use ?populate=fieldName to inline the full related entry instead of just the UUID.
// Schema definition
{
"name": "author",
"type": "relation",
"options": {
"relatedContentType": "team",
"cardinality": "one"
}
}
// Default API output — returns the publicId UUID
"author": "a1b2c3d4-e5f6-4abc-8def-000000000001"
// With ?populate=author — returns the full entry inline
"author": {
"slug": "jane-doe",
"data": { "name": "Jane Doe", "role": "Editor" }
}
// cardinality: "many" — array of UUIDs or populated entries
"tags": ["uuid-1", "uuid-2"]
Entries & Slugs
Entries are the data records for a content type. Each entry has a slug, a status, and a data object containing all field values.
Slugs
Auto-generated from the first text field. Editable later, but changing it breaks existing links/SEO. Must be unique per content type.
Status
published entries are public. draft entries are hidden from the public API.
Scheduling
Set a publishAt date to automatically publish an entry in the future.
Versions
Every save creates a version snapshot. Restore any previous version from the entry editor.
Soft delete
Deleted entries are soft-deleted (hidden, not removed). Restore from the admin panel if needed.
SEO
Each entry has optional SEO fields: title, description, image, and noIndex toggle.
Row actions
Each row has Edit, Duplicate, Copy URL (copies the entry's public API URL, e.g. /api/article-page/my-post), and Delete.
Media Library
Upload and manage files through the admin panel. Images are automatically optimised and converted to WebP.
Allowed types
Limits
Max file size: 10MB. Images are resized to a max of 2400px and converted to WebP automatically.
backend/uploads/ by default. Set STORAGE_DRIVER=s3 to use S3, Cloudflare R2, or any S3-compatible service.
API Keys
API keys let external apps read or write content without a user login. Send the key in the X-API-Key header.
| Access level | Can do | Rate limit |
|---|---|---|
| read | GET requests only | 120 req/min |
| write | POST / PUT / PATCH / DELETE | 60 req/min |
| all | Read + Write combined | 120 req/min |
Changing data with an API key — step by step
Create the key once (admin), then every change uses only the X-API-Key header — no login. Content-type URLs use hyphens (a name like blog_posts is reached at /api/blog-posts).
- Create a key in Developer → API Keys (admin only): access
write(POST/PUT) orall(also DELETE), scoped to your content type. Thenp_…key shows once — copy it into your server's env. - Create —
POST /api/<type>with a top-levelslugand adataobject. - Update —
PUT /api/<type>/<slug>— slug in the URL, body has onlydata. - Read back —
GET /api/<type>/<slug>— public, no key. - Delete —
DELETE /api/<type>/<slug>— requires anallkey.
A 401 means the key is missing or mistyped; a 403 means the key is valid but not allowed (wrong access level, or not scoped to that content type).
⚠ Keep write keys server-side
A write/all key can modify your content, so it must never ship to the browser. Don't put it in client-side JavaScript or a NEXT_PUBLIC_ variable — anything in the browser is readable by every visitor.
To write from a public website, proxy through your own server: the browser calls your route, and your route (holding the key in a server-only env var) forwards the write to NodePress. The API key bypasses role checks, so your route is the gatekeeper — validate and authorize before forwarding.
Browser → your server → NodePress
Example using a Next.js route handler — the key stays on the server, the browser never sees it:
For visitor-generated content (comments, contact, reviews), prefer the public POST /api/submit/:slug Forms endpoint — it needs no key and has rate limiting, honeypot, and optional captcha built in.
Forms
Build forms in the admin panel and embed them in your frontend. Each form gets a public submission endpoint — no auth or API key required. Submissions are stored in the database and can trigger email or webhook actions. Forms support rich nested fields (groups + repeaters), typed scalars and arrays, and optional per-field validation; submissions can be browsed (nested values expanded) and exported to CSV from the admin.
Submit a form (public API)
POST /api/submit/:slug — the slug is set when you create the form. Wrap your field values in a data object whose keys match the form's field names:
Form field types
Each field supports optional declarative validation (min/max, length, pattern, item counts). Use group and repeater to nest objects and arrays-of-objects.
| Type | Description | Example value |
|---|---|---|
| text | Single-line text. Good for names, subjects, short answers. | "Acme Inc" |
| textarea | Multi-line text. Good for messages, comments, longer answers. | "Line one\nLine two" |
| number | Numeric value — integer or decimal. Validated to be a number. | 42 |
| Email address. Validated server-side — must be a valid address. | "jane@example.com" | |
| url | Web address. Validated to be a well-formed URL. | "https://example.com" |
| phone | Phone number. Validated against a default or custom pattern. | "+1 555 123 4567" |
| date | Calendar date. Stored normalized as YYYY-MM-DD. | "2026-06-25" |
| datetime | Date with time. Stored normalized as ISO 8601. | "2026-06-25T14:30:00Z" |
| boolean | Yes/No toggle. Good for consent, terms agreement. | true |
| select | Dropdown — pick one from a predefined list. Options set in admin. | "premium" |
| radio | Radio buttons — pick one option. Options set in admin. | "yes" |
| multiselect | Pick many from a predefined list. Stored as an array. | ["sms", "email"] |
| tags | Freeform array of strings — user adds their own values. | ["urgent", "vip"] |
| group | Nested object — bundle related sub-fields together. | { "city": "Pune", "pincode": "411001" } |
| repeater | Repeatable list of objects (array-of-objects) with shared sub-fields. | [{ "url": "https://…", "type": "pdf" }] |
Spam protection
Every submission endpoint has three layers built in — they stack:
- Rate limiting (always on) — 20 submissions per minute per IP, then HTTP 429.
- Honeypot (always on) — add a hidden
_honeyfield; bots that fill it are silently dropped. - Captcha (opt-in per form) — Cloudflare Turnstile, hCaptcha, or Google reCAPTCHA v2/v3. Toggle the “Spam Protection (Captcha)” switch in the form builder.
Enabling captcha — what to change in .env
Captcha verification needs a provider configured in backend/.env. Get a free key from Cloudflare Turnstile (recommended — no puzzle), hCaptcha, or Google reCAPTCHA, then:
If CAPTCHA_PROVIDER is left unset, the captcha layer no-ops (fail-open) so forms keep working in development — rate limiting and the honeypot still apply.
Email notifications (SMTP setup)
A form's Email action only sends mail once SMTP is configured in backend/.env. Until then, submissions are still saved but no email goes out (the backend logs a warning, never crashes). Restart the backend after editing .env — the mail connection is created once at startup.
Gmail (quick start)
Gmail rejects your normal account password over SMTP. You need a 16-character App Password, which requires 2-Step Verification first:
- Enable 2-Step Verification: myaccount.google.com/signinoptions/two-step-verification
- Create an App Password: myaccount.google.com/apppasswords — choose Mail / Other, name it "NodePress", copy the 16-char value.
- Use that value (no spaces) as
SMTP_PASS, then restart the backend.
For production, a transactional provider (Resend, Brevo, Mailgun, SendGrid, Postmark) gives better deliverability than Gmail (which caps at ~500 msgs/day) — same five variables, just different host/user/pass.
Spam protection — captcha (Cloudflare Turnstile)
Public forms attract bots. NodePress always applies rate limiting and an optional honeypot field; for stronger protection, enable a captcha. Cloudflare Turnstile is recommended — it's free and usually invisible to real visitors. It supports hCaptcha and Google reCAPTCHA too.
The two keys — where each one goes
Cloudflare gives you two keys. They live in two different places and must never be swapped:
| Key | Where you paste it | Public? |
|---|---|---|
| Site Key | In your website's form HTML — the data-sitekey attribute. Not in .env. | Yes — safe to be visible |
| Secret Key | In backend/.env as CAPTCHA_SECRET_KEY. Never in website code or git. | No — keep private |
Step-by-step
- Open dash.cloudflare.com → Turnstile (free; your domain need not be on Cloudflare).
- Click Add site, name it, add your domain(s) — include
localhostwhile testing — widget type Managed. - Copy the Site Key and Secret Key it shows.
- Paste the Secret Key into
backend/.env(below) and restart the backend. - In the admin, open your form → turn on the Spam Protection (Captcha) switch → Save.
- Paste the Site Key into the widget on your website form (below).
Setting CAPTCHA_PROVIDER without CAPTCHA_SECRET_KEY stops the backend from starting — set both or neither. Local test keys: Site Key 1x00000000000000000000AA, Secret Key 1x0000000000000000000000000000000AA.
Backend — the Secret Key goes here
Website form — the Site Key goes here
Brand & Theme
Set your install's identity at Settings → Brand (admin only). It is stored on the server — shared across every browser and device — and applied automatically everywhere.
| Setting | Applies to |
|---|---|
| Name | Sidebar, browser tab title, login & setup pages, form-submission emails. |
| Logo | Sidebar, login page, browser favicon, email header. Uploaded via the media library. |
| Accent colour | Sidebar highlight and the email header bar. |
Theme (optional)
The Theme card recolours the admin UI:
- Button colour — primary buttons. Button text colour auto-adjusts (black/white) for readability.
- Input colour — input field border and focus ring.
Both are optional — leave blank (or hit Reset) to use the built-in theme default. One colour applies in both light and dark mode, and changes take effect immediately after saving.
Team & Roles
Invite teammates from Users → Add User (admin only). Onboarding is invite-only — you never set a password for someone else.
Inviting a member
Enter just an email + role. NodePress creates the account and emails the person a secure link to set their own password (valid 7 days); admins never see or set it. Use Resend invite if the link expires. If no SMTP server is configured, the invite link is shown so you can copy and send it manually (requires SMTP_* in backend/.env to email automatically — see the Forms section for Gmail setup).
Roles
| Role | Can do |
|---|---|
| admin | Everything — users, content types, entries, media, settings. |
| editor | Create, read, update, delete, publish any entry or media. |
| contributor | Create and update entries (cannot delete or publish). |
| viewer | Read-only access to the admin panel. |
Access is enforced server-side. Per-content-type overrides are available under Users → Permissions.
Webhooks
Webhooks let external systems react in real time when content changes — a reverse API call. Instead of another app polling NodePress asking "anything new yet?", NodePress automatically POSTs a JSON payload to a URL you register the moment a matching event happens.
What you'd use them for
Rebuild a static site
A post is published → ping your Vercel / Netlify deploy hook → your live site rebuilds with the new content automatically.
Team notifications
New entry or media upload → POST to a Slack / Discord incoming webhook → your team sees it in a channel.
Sync to another system
Entry created or updated → push to a search index (Algolia), a CRM, an email list, or an analytics tool.
Clear a CDN cache
Entry updated → tell Cloudflare / Fastly to purge the cached page so visitors get fresh content.
How to set one up
1. Open Webhooks — Developer → Webhooks (admin only), click New Webhook.
2. Set the target URL — paste the URL NodePress should POST to (e.g. your Vercel deploy hook or a Slack incoming webhook URL).
3. Pick events — choose which events fire it, or select * for all.
4. Add a secret (recommended) — every delivery is then HMAC-SHA256 signed so your receiver can verify it genuinely came from NodePress.
5. Test Ping — send a sample event to confirm your endpoint is reachable, then check the Delivery Log for the result.
6. Edit or remove — each webhook card has Edit (change name, URL, secret, or events), enable/disable, Test Ping, and Delete actions.
Events
entry.created
entry.updated
entry.deleted
entry.restored
entry.purged
media.uploaded
media.deleted
* (all events)
Retry logic
Failed deliveries are retried up to 3 times: immediately, after 5 min, after 30 min. HMAC-SHA256 signature in X-NodePress-Signature header.
The POST body's data contains only id, slug, status, and contentType — not your field values. If your receiver needs the complete content, fetch it back with GET /api/{contentType}/{slug} using the slug from the payload.
GraphQL API
NodePress exposes a full GraphQL API at /graphql alongside REST. Apollo Sandbox (interactive playground) is available in all environments — click GraphQL Playground in the Developer section of the admin sidebar.
This is a browser cache issue — the browser cached a response with old security headers. Fix: open an Incognito window (works immediately) or press Ctrl+Shift+Delete → clear Cached images and files → refresh. Happens only once after a server restart.
Queries
Entry mutations
Content type mutations (admin only)
Webhook mutations (admin only)
Authentication
Public queries (entries, contentTypes) work without auth and return only published entries. Add Authorization: Bearer YOUR_JWT_TOKEN header for mutations and protected queries. Query depth is limited to 6 levels to prevent abuse.
Real-time (WebSocket)
NodePress broadcasts content changes over WebSocket using Socket.io at /api/realtime. Subscribe from your frontend to receive live updates without polling.
Events received
entry:created
entry:updated
entry:deleted
entry:restored
media:uploaded
media:deleted
Rooms
All connections join the global room automatically. Subscribe to a specific content type room to filter events:
ct:blog, ct:productsHow to get your Bearer token
Option A — Login API: call POST /api/auth/login with your email + password. The response contains access_token. Use it as Bearer <access_token>.
Option B — Browser cookie: log into the admin panel, open DevTools → Application → Cookies → find np_token. That value is your Bearer token (valid for 7 days).
Option C — API key: create one in Admin → API Keys. Pass it as auth.apiKey — no expiry.
SEO & Sitemap
Sitemap
Auto-generated at GET /api/sitemap.xml. Includes all published entries except those flagged noIndex. Set SITE_URL in your env.
Robots.txt
Served at GET /api/robots.txt. Configure blocked paths via ROBOTS_DISALLOW env var.
Self-Hosting
NodePress runs anywhere Node.js does. Two paths to go live — either way you need a PostgreSQL database and, for production, S3-compatible storage for media.
Path A — Docker on your own server
A VPS with the bundled docker-compose.prod.yml — Postgres, Redis, nginx, Prometheus + Grafana, and DB backups in one command. Most control.
Path B — Any managed host (PaaS)
Render, Railway, Fly.io, DigitalOcean App Platform, etc. Deploy backend + frontend as two web services from GitHub, plus a managed Postgres. No server to patch.
Deploy to any managed host (Path B)
Host-agnostic — only the dashboard differs between providers.
NodePress is two programs — a NestJS API and a Next.js frontend. Locally npm run dev just launches both at once with concurrently (API on :3000, frontend on :5173), so it looks like one command. Managed hosts run one process per service, so each gets its own service from the same repo. Prefer a single command on one box? Use the Docker path below — nginx serves both behind one URL.
1. Push to GitHub — most hosts deploy from a repo. Keep .env out of git (the scaffold already gitignores it).
2. Provision Postgres + storage — a managed PostgreSQL (Neon, Supabase, or the host’s add-on) and an S3-compatible bucket (Cloudflare R2 recommended).
3. Backend service — root dir backend; build npm install && npx prisma generate && npm run build; start npx prisma migrate deploy && npm run start:prod. The host injects PORT.
4. Frontend service — root dir frontend; build npm install && npm run build; start npx next start -p $PORT.
5. Wire the URLs — deploy backend first, then set the frontend’s BACKEND_URL to it, and the backend’s APP_URL / SITE_URL / CORS_ORIGIN to the frontend URL. Redeploy.
6. Create the first admin — open https://your-frontend-url/setup (works only while the DB has zero users).
JWT_SECRETmust be ≥32 characters — otherwise the backend exits on boot.- Use
STORAGE_DRIVER=s3in production. On managed hosts the local disk is wiped on every redeploy, so local uploads vanish while theMediarows survive — broken images. - Replace every
localhostURL with the real public URLs, or the live site can’t reach the API.
Deploy with Docker on a VPS (Path A)
Scaffold with --docker (or copy docker-compose.prod.yml + nginx/ from the repo), fill in backend/.env, then start the whole stack with one command.
Environment variables
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string required |
JWT_SECRET | 64+ char random secret for auth tokens required |
CORS_ORIGIN | Allowed frontend origin (comma-separated for multiple) required |
PORT | API port (default 3000) |
APP_URL | Backend URL — used in API responses |
SITE_URL | Public site URL — used in sitemap.xml |
REDIS_URL | Redis URL — enables shared cache (optional) |
STORAGE_DRIVER | local (default) or s3 |
STORAGE_S3_BUCKET | S3/R2/MinIO bucket name (if STORAGE_DRIVER=s3) |
SMTP_HOST | SMTP server for password reset emails |
METRICS_TOKEN | Bearer token to protect GET /api/metrics |
Docker (production)
API Reference
All endpoints are prefixed with /api. Public GET endpoints require no auth. Write endpoints require Authorization: Bearer <token> or X-API-Key.
Auth
/api/auth/loginEmail + password → returns JWT access token (7d) + sets refresh cookie (30d)
/api/auth/meReturns current user from token
/api/auth/refreshExchange refresh token for new access token (silent rotation)
/api/auth/forgot-passwordRequest password reset email. In dev without SMTP, returns devResetUrl in response.
Content (Public)
/api/:typeList all published entries for a content type. Supports ?page, ?limit, ?status
/api/:type/:slugGet a single published entry by slug
/api/:typeCreate a new entry
/api/:type/:slugUpdate an entry
/api/:type/:slugSoft-delete an entry
Media
/api/mediaList all uploaded files
/api/media/uploadUpload a file (multipart/form-data, field: file)
/api/media/:idDelete a file by ID
Other
/api/submit/:slugSubmit a form (no auth required)
/api/healthHealth check — DB connectivity
/api/sitemap.xmlAuto-generated sitemap with all published entries
/api/docsInteractive Swagger UI
/graphqlGraphQL endpoint — Apollo Sandbox playground (GET) + API (POST). All environments.
/api/metricsPrometheus metrics (optional METRICS_TOKEN bearer auth)