8 MIN READ · Pedro Thomaz

A Headless CMS With No Build Step: PHP + Cockpit, Server-Rendered

You can run a headless CMS without a JS build step. Here's how we pair Cockpit v2 with server-rendered PHP, FTP deploys, and zero npm pipeline.
A Headless CMS With No Build Step: PHP + Cockpit, Server-Rendered

You can run a headless CMS without a JavaScript build step. Pair a content API like Cockpit v2 with server-rendered PHP, and the editing experience stays decoupled and modern while the front end ships as plain HTML — no bundler, no node_modules on the server, no rebuild on every content change. This is the stack that runs the site you're reading, and below is exactly how it's wired, where it wins, and where it would be the wrong call.

What "headless CMS without a build step" actually means

A headless CMS stores content and exposes it over an API, with no opinion about how you render it. Cockpit CMS v2 is a self-hosted PHP headless CMS: you define collections (think "journal", "portfolio", "team") as JSON models, edit entries in its admin UI, and read them back over a REST API. The "head" — the thing that turns content into pages — is entirely yours.

"No build step" is the part people get wrong. It does not mean no tooling at all. It means no compilation between your source code and what the server runs. There is no bundler transforming .jsx or .ts into deployable artifacts, no framework that demands npm run build before a single page exists, no dist/ directory that is the real source of truth. Our PHP files are the deployable. The browser gets server-rendered HTML on the first byte.

Concretely, the front end here is PHP 8.3 on OVH shared hosting, behind Cloudflare. A request hits router.php, which dispatches to a page like journal-post.php, which calls Cockpit's API, gets back JSON, and echoes HTML. That's the whole pipeline. There is no React, no hydration, no client-side router.

The short version

Why a PHP headless CMS instead of an SSG

The default 2020s answer to "headless CMS front end" is a static site generator — Next.js, Astro, Gatsby — pulling from the API at build time and emitting static files. That's a genuinely good architecture. We reach for it on plenty of projects. But it carries a tax that nobody mentions in the tutorial.

An SSG couples publishing to a build. Edit a typo in the CMS, and the page doesn't change until a build runs and redeploys. To hide that latency you wire up webhooks, incremental static regeneration, a build queue, a hosting platform that runs the builds, and a cache-invalidation story for when ISR gets stale. None of it is hard in isolation. All of it is moving parts that can break at 11pm on a Friday, and most of it exists to paper over the fact that you turned a content edit into a software deployment.

Server-rendered PHP collapses that. Content is data, fetched at request time. An editor saves in Cockpit, the change is live on the next request (or the next cache expiry — seconds, not a build). There is no webhook, no build minute, no regeneration job. The "deploy" only happens when code changes, which on a marketing site or studio journal is dramatically less often than content changes.

This is the core thesis: for content-driven sites where editors publish more often than developers deploy, decoupling content from the build is worth more than the raw performance of pre-rendered static files — especially once an edge CDN is doing the heavy lifting on cache anyway.

The deploy story: FTP, minify, purge

Because there's no build artifact, the deploy is almost insultingly simple. The whole thing is one script:

./deploy.sh   # minify → FTP mirror → Cloudflare purge

Three steps:

  1. Minify. We regenerate assets/css/main.min.css with rcssmin and assets/js/*.min.js with terser — but only if the source is newer than the output. This is the only "compilation" in the system, it's optional, and it runs on the developer's machine. The server never sees a toolchain.
  2. FTP mirror. lftp mirrors the working directory to /www/ on OVH, additive only (no --delete), excluding storage/, the cockpit/ install, secrets, .git/, and node_modules. The PHP files you wrote are the PHP files that run. There is no transpilation in between to debug.
  3. Cloudflare purge. A purge_everything API call busts the edge cache so the new code is live globally in seconds.

We run ./deploy.sh --dry-run first whenever the exclude list changed, so lftp prints what it would upload before anything moves. No CI/CD platform, no build minutes, no YAML pipeline. This script is the CI/CD. A junior could read it top to bottom in two minutes and understand the entire path from laptop to production — which is itself a reliability feature.

SQLite as the CMS database: the honest tradeoffs

Cockpit v2 defaults to SQLite, a single file on disk, and we keep it that way. This surprises people, so let's be precise about when it's right and when it isn't.

Where SQLite wins for a headless CMS:

Where SQLite is the wrong tool — be honest about this:

The rule we use: if the content is edited by a small team and read by the world, SQLite is not a compromise — it's the correct, boring, durable choice. If the database is the application (user-generated content, high write fan-out, multi-tenant scale), it isn't.

i18n per field, not per build

This site is trilingual — English, Portuguese (pt-PT), and Spanish — and the i18n model is where the no-build approach really pays off. Translation lives per field, inside the CMS. Cockpit stores localized variants alongside the base field: a journal entry has title, title_pt, title_es and the same for excerpt and body. An editor translates a post in the same admin screen, no code involved.

The front end resolves locale from the URL prefix. /pt/journal/... serves Portuguese, /es/... serves Spanish, bare paths serve English. The PHP template picks the right field at render time — roughly:

$lang  = current_locale();          // 'en' | 'pt' | 'es'
$title = $entry['title_' . $lang] ?? $entry['title'];

The payoff: adding a translation never triggers a rebuild. Save the field in Cockpit, the localized page is live on the next request. With an SSG, three languages can mean three times the build surface and a matrix of locale routes regenerated on every change. Here it's one extra field lookup at request time. (We learned the hard way to not auto-redirect visitors to /pt/ or /es/ based on navigator.language — it hijacks the URL people actually asked for. Locale is a choice, not a guess.)

When this stack beats Next.js / Astro — and when it doesn't

Choose server-rendered PHP + headless CMS when:

Reach for Next.js or Astro instead when:

There's no universal winner. The mistake is treating "headless CMS" as a synonym for "JavaScript SSG." Headless just means the content is decoupled from the rendering. PHP rendering that content per request is every bit as "headless" as a Next.js build consuming the same API — it simply moves the work from build time to request time, and on a content site behind a CDN that's frequently the better trade.

FAQ

Is a headless CMS without a JavaScript build step actually viable in production?

Yes. When the front end is server-rendered PHP, the CMS only needs to expose content over an API or shared database, so there is no client bundle to compile, no node_modules, and no build pipeline to maintain. We run Cockpit v2 behind server-rendered PHP 8.3 templates and ship plain HTML, CSS, and a little vanilla JavaScript, which means a deploy is a file sync rather than a build job.

How does Cockpit compare to Next.js static-site generation (SSG)?

Cockpit is a content store you read at request time from PHP, whereas Next.js SSG pre-renders pages into static HTML at build time using a Node toolchain. SSG gives you CDN-cacheable pages at the cost of a build-and-redeploy cycle on every content change; server-rendered PHP reading Cockpit serves fresh content on each request with no rebuild, which suits editorial sites where content changes often and the team does not want a JS toolchain.

Can SQLite handle a real CMS workload?

For read-heavy content sites it handles it comfortably. Cockpit v2 defaults to SQLite, and a single-file database with proper indexes easily serves the read volume of a brochure or editorial site, especially with HTTP caching in front. We reach for MongoDB or Postgres only when we need concurrent heavy writes or multi-server replication, which most content sites never hit.

What do you give up by skipping the build step?

Mainly the conveniences a bundler provides: tree-shaking, TypeScript compilation, JSX, and automatic asset hashing for cache-busting. We handle those manually where they matter, using native ES modules, hand-versioned asset URLs, and small dependency-free scripts, which keeps the codebase debuggable in the browser exactly as written.

How do you cache content when PHP renders on every request?

We cache at the HTTP layer rather than at build time. Cockpit responses and rendered pages are cached with appropriate Cache-Control headers and a reverse proxy or CDN, so repeat requests are served without touching PHP or the database, while content edits invalidate the cache instead of triggering a redeploy.

The limits, stated plainly

We don't pretend this is free of edges:

We build the JavaScript-framework version too — it's the right answer for plenty of client work. But for a content-driven site maintained by a small team, a headless CMS with no build step is faster to ship, cheaper to run, and dramatically calmer to operate. The best build step is frequently the one you didn't add.