CREATE YOUR OWN HEADLESS CMS
Off-the-shelf CMS platforms are convenient until they aren't. The moment you need an unusual content model, a custom workflow, or full control over how content is stored and served, you're fighting the tool instead of building your product. Building your own headless CMS isn't as daunting as it sounds. It's fundamentally a CRUD API with authentication, file handling, and a publishing workflow layered on top.
This tutorial builds a production-capable headless CMS backend using Express, JWT authentication, markdown rendering via the unified ecosystem, image uploads, and a draft/publish workflow. We'll use SQLite for storage — it requires zero infrastructure for development, and the same schema works with PostgreSQL if you swap the driver.
ARCHITECTURE OVERVIEW
The project is organized into a few clear layers. The middleware folder handles JWT verification and file upload configuration. The routes folder defines three API surfaces: /api/auth for registration and login, /api/posts for content CRUD, and /api/media for image management. The services folder encapsulates the database connection and the markdown rendering pipeline. Uploaded files land in an uploads directory, and the SQLite database lives in a data directory.
That's it. No magic, no framework opinions about your content model — just Express handlers and a database.
STEP 1: PROJECT SETUP
Start by initializing the project and installing dependencies:
npm install express better-sqlite3 bcryptjs jsonwebtoken multer unified remark-parse remark-html slugify dotenv cors
And dev dependencies:
npm install -D typescript @types/express @types/better-sqlite3 @types/bcryptjs @types/jsonwebtoken @types/multer ts-node @types/node nodemon
Your tsconfig.json should target ES2022 with CommonJS modules, strict mode on, rootDir pointing to src, and outDir pointing to dist.
Create a .env file at the root with two variables: JWT_SECRET set to a long random string, and PORT set to 3001.
STEP 2: DATABASE SETUP
The database service file creates the SQLite connection and initializes the schema. Enable WAL mode (journal_mode = WAL) for better concurrent read performance — this is a single pragma call before you run any queries.
The schema has three tables. The users table stores email, a bcrypt password hash, a role (either "editor" or "admin"), and a created_at timestamp. The posts table stores title, a unique slug, an excerpt, both the raw markdown and the rendered HTML, a status field ("draft" or "published"), an optional featured image path, an author reference, a published_at timestamp, and created/updated timestamps. The media table stores uploaded file metadata: filename, original name, MIME type, file size, the public URL, and who uploaded it.
The first registered user automatically becomes admin. Every subsequent user gets the "editor" role.
STEP 3: MARKDOWN RENDERING
The markdown service uses the unified processor chain: remark-parse converts markdown text into an AST, then remark-html serializes that AST to HTML. This is a proper AST-based approach rather than regex replacement, which means it handles nested formatting, code blocks, and edge cases correctly.
The service exports two functions. markdownToHtml takes a markdown string and returns a promise that resolves to the rendered HTML. extractExcerpt strips all markdown syntax and returns a plain-text preview truncated to 160 characters — useful for meta descriptions and listing pages.
When a post is created or updated, both functions run and their output gets stored alongside the raw markdown. This way your frontend never has to render markdown at runtime.
STEP 4: AUTHENTICATION
The JWT middleware is a single function that reads the Authorization header, strips the "Bearer " prefix, and verifies the token against JWT_SECRET. If verification succeeds, it attaches the decoded payload (id, email, role) to the request object and calls next(). If the header is missing or the token is invalid, it returns a 401.
There's also a requireRole factory function that takes "admin" or "editor" and returns a middleware that checks the attached user's role. This is what locks down delete operations to admins only.
The auth routes handle registration and login. Registration hashes the password with bcrypt (cost factor 12), checks if this is the first user ever (using a COUNT query), and inserts with the appropriate role. Login fetches the user by email, compares the submitted password against the stored hash using bcrypt.compare, and if it matches signs a 7-day JWT containing the user's id, email, and role.
STEP 5: POSTS CRUD
The GET / route is intentionally dual-purpose. If the request has an Authorization header, it returns all posts in creation order. If not, it returns only published posts ordered by publish date. This lets the same endpoint serve both the public-facing site and the authenticated admin interface.
The POST / route requires authentication. It takes a title and content_markdown from the request body, generates a slug using the slugify library, checks for slug collisions (appending a timestamp if one exists), renders the markdown to HTML, extracts an excerpt, and inserts everything in one prepared statement. The published_at timestamp is only set if status is "published" at creation time.
The PATCH /:id route handles updates. Editors can only update their own posts; admins can update any post. The update uses COALESCE so only provided fields are changed — you can send just a status update without touching the content, or just update the title without re-rendering the markdown. The published_at timestamp is set server-side the first time a post transitions to "published", so the original publish date is preserved even if you later revert to draft and re-publish.
The DELETE /:id route is admin-only via requireRole('admin').
STEP 6: IMAGE UPLOADS
The upload middleware uses multer with disk storage. Each uploaded file gets a random 16-byte hex filename (with the original extension preserved) stored in the uploads directory. The file filter accepts only JPEG, PNG, WebP, and GIF. File size is capped at 5MB.
The media route is a single POST endpoint that accepts a multipart form upload with a field named "file". It stores the file metadata in the media table and returns the created record. The URL is simply /uploads/filename, which the main entry point serves statically via express.static.
To swap disk storage for S3, replace the multer diskStorage config with multer-s3 pointing at an S3 bucket and pass in your AWS credentials. The route handler stays completely unchanged.
STEP 7: WIRING IT ALL TOGETHER
The main entry point is about 15 lines. It loads dotenv, creates the Express app, enables CORS and JSON body parsing, mounts the uploads directory as a static file server, and registers the three route modules at their respective paths. Then it calls app.listen.
Start the server with npx ts-node src/index.ts. You now have a running CMS API with authentication, content management, image uploads, and a draft/publish workflow.
STEP 8: THE DRAFT/PUBLISH WORKFLOW IN PRACTICE
From a frontend or API client perspective, the workflow is simple. To publish a draft, send a PATCH request to /api/posts/:id with a body of { "status": "published" } and a valid Bearer token. To revert to draft, send the same request with { "status": "draft" }. The server handles the published_at bookkeeping automatically.
The status field is the single source of truth. The public listing endpoint at GET /api/posts only returns posts where status is "published", so nothing shows up on your frontend until you explicitly publish it.
WHAT TO BUILD NEXT
- Scheduled publishing: add a publish_at column and a cron job that promotes drafts when publish_at is in the past
- Revisions: store a copy of every post update in a post_revisions table so editors can roll back to any previous version
- Tags and categories: add a tags table and a posts_tags join table for many-to-many relationships
- S3 uploads: swap the disk storage multer config for multer-s3 with AWS SDK credentials — the route stays unchanged
- Frontend editor: pair this API with a React app using CodeMirror for the markdown editor and a live preview panel showing the rendered HTML alongside
Back to DIY Tutorials
DIY TutorialAdvanced
Create Your Own Headless CMS
Build a custom headless CMS with markdown support, authentication, and a REST API.
February 20, 202460 min read
CMSAPIMarkdownAuthentication