
I run several projects - Jurter (livestock management), Dowell (habit tracking), Labas (language practice), and Rekkoku (travel recommendations). Each has its own website, audience, and blog content.
For a while, each project blog lived in isolation. Some had their own CMS, others didn't which meant writing blog posts meant logging into different systems, managing spearate databases, and repeating the sam editorial workflow across projects.
I wanted a single place to manage all my project blogs. One dashboard. One database. One login. But each project should only see its own content when they request it.
Here's how I solved it.
The idea is simple: treat each project blog like a tenant in the same CMS. Posts can belong to either:
When you write a post in the dashboard, you optionally assign it to a project. If you don't, it appears on the main site (rogasper.com). If you do, it only shows up when that project fetches its content.
The key insight: existing posts don't need to change. Any post without a project assignment automatically belongs to the main site. This made the migration zero-drama.
The system has two audiences, so it has two entry points:
Audience | How They Authenticate | What They Can Do |
Admin (me) | Login with email/password | Write posts, manage projects, assign content |
External projects | API Key ( a secret token) | Fetch published posts assigned to them |
Behind the scenes, both hit the same database. The separation happens through a simple projectId field on each post. If it's null, it's a main site post. If it has a value, it belongs to that project.
The admin panel is a Next.js app with shadcn/ui components. The API is built with Express + Prisma. The database is PostgreSQL.
Each project gets a unique 64-character hex key when created. This key is used in the x-api-key header for every API request.
The flow:
If the key is ever compromised, the admin can regenerate it with one click. The old key stops working immediately.
Why an API key instead of JWT? Because external project blogs don't have users. They're static sites or server-rendered apps that just need to show blog posts. An API key is simpler no login flow, no token refresh, no user management. One key per project, shared across the team.
The entire system rests on one new database table: mst_projects. Four main columns matter:
And one new column on the existing posts table: projectId. It's nullable if empty, the post belongs to the main site.
When a project is deleted, its posts don't get deleted. They get unassigned back to the main site. I didn't want to accidentally lose content because someone hit delete.
projects consume their content through three simple endpoints:
Returns published posts with title, slug, thumbnail, author, categories, and timestamps. Includes pagination metadata so the client knows how many pages exist.
Returns the full post content. The server double-checks the post actually belongs to the requesting project no cross-project data leakage.
Or in a Next.js server component:
That's it. A couple of fetch calls and your project has a full blog powered by the centralized CMS.
Managing projects from the dashboard feels like any other CRUD page in the admin panel:
Create a project. Enter a name and optional description. The system generates the slug and API key automatically. A modal shows you the API key with a copy button and a warning: "Save this now. You won't see it again."
Edit. Update the name or description. Toggle the project active/inactive.
Regenerate key. Click a button, confirm, and a new key replaces the old one. Instant revocation.
Assign posts. In the post editor, there's a dropdown to select which project the post belongs to. Default is "Main site (no project)." The change is instant/
The posts table now shows a "Project" column with a badge "Main site" or the project name. At a glance I can see which content goes where.
The main site blog (rogasper.com/blog) shouldn't show project posts. But I didn't want to add query parameters to the frontend or change exsiting code.
The solution was elegant: the public API defaults to "no project" if no project is specified.
When the main site fetches posts, it doesn't pass projectId. The API automatically filters to only posts where projectId IS NULL. The frontend never needed to change.
If someone directly accesses a project post URL from the main domain, the API silently returns 404. No redirect, no error it simply doesn't exist from the main site's perspective.
One mistake that caused a production headache: we wrapped multiple database queries in a Prisma transaction. Transactions hold a single database connection until all queries complete. With several admin functions each running 3-9 queries in a single transaction, the connection pool (default: 10) quickly exhausted.
The fix was replacing transactions with Promise.all() for read-only queries. Since each query is already atomic, wrapping them in a transaction provides zero benefit while costing a connection slot.
Lesson learned: transactions are for writes that must succed or fail together. Not for independent read queries.
There are a few things I'd improve:
API key storage. Currently stored as plain text. For read-only access to published content, the risk is acceptable. But for write endpoints (planned), I'd switch to hashed keys or HMAC signing.
Rate limiting. Each project should have a usage limit. Currently there's a global limiter, but per-project limits would be better for isolation.
Documentation. I should build a self-service documentation page in the admin panel so project owners can see how to integrate without asking me.
One dashboard. One database. Multiple project blogs.
The system handles content isolation, API-based consumption, and editorial workflows from a single interface. Adding a new project takes about 30 seconds create it, get the key, configure the client.
The blog on rogasper.com never broke. Existing posts stayed on the main site. New posts can optionally target specific projects. Everything just works.
If you're managing multiple projects with their own blogs, a centralized CMS with project scoping might be simpler than you think. One extra table, one nullable column, and a middleware function that's the entire surface area.