Skip to content
    2025-07-15|5 min read

    Building a Custom CMS for Non-Technical Clients

    #cms#nextjs#headless#react#architecture

    Every developer who builds websites for clients eventually faces the same question: "How do I update this myself?"

    The default answer — "Install WordPress" — comes with maintenance burden, security vulnerabilities, and performance penalties. The alternative — "Learn Git and Markdown" — isn't realistic for most business owners.

    The solution is a custom headless CMS. This article covers the architecture decisions I've made building CMS platforms for non-technical clients, including the custom CMS I built for CanvasInc.

    Why Build Custom?

    Off-the-shelf solutions like Contentful, Sanity, or Strapi are excellent products. But they come with limitations:

    • Pricing scales with usage — Contentful charges per API call, which becomes expensive at scale
    • UI complexity — Sanity's structured content approach confuses non-technical editors
    • Hosting responsibility — Strapi requires you to manage a Node.js server

    A custom CMS, built specifically for your client's content model, eliminates these pain points. It does exactly what they need and nothing they don't.

    Architecture Overview

    The system has three parts:

    1. CMS Admin — a Next.js app with authentication, page builder, media library
    2. API Layer — Next.js API routes that serve content to the frontend
    3. Public Site — a separate Next.js app consuming the API

    Database Design

    I use PostgreSQL with Prisma for the content store. The schema is designed around the concept of "content blocks" — reusable components that editors compose into pages.

    ```prisma model Page { id String @id @default(cuid()) slug String @unique title String published Boolean @default(false) sections Section[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

    model Section { id String @id @default(cuid()) pageId String page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) type String // hero, text, gallery, cta, testimonial, pricing order Int content Json // type-specific data stored as JSON createdAt DateTime @default(now()) } ```

    The content field stores arbitrary JSON matching the section type's schema. This keeps the database schema stable while allowing infinite content flexibility.

    The Page Builder

    The heart of the CMS is the drag-and-drop page builder. I use @dnd-kit for drag and drop with a component-based architecture.

    ```tsx import { DndContext, closestCenter } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

    function PageBuilder({ sections, onUpdate }) { return ( <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <SortableContext items={sections} strategy={verticalListSortingStrategy}> {sections.map((section) => ( <SortableSection key={section.id} section={section} onChange={(updated) => onUpdate(section.id, updated)} /> ))} </SortableContext> </DndContext> ); } ```

    Each section type has a corresponding editor component:

    • Hero: Image upload, headline, subheadline, CTA button text and URL
    • Text: Rich text editor (I use TipTap/ProseMirror), alignment options
    • Gallery: Image grid upload with drag-to-reorder
    • Testimonials: Name, role, photo, quote text
    • CTA: Background color, headline, button configuration
    • Pricing: Tier name, price, features list, call-to-action

    Media Management

    Clients upload images, PDFs, and videos. The system:

    1. Receives upload via API route
    2. Uploads to Cloudinary or AWS S3
    3. Generates thumbnails and optimized variants
    4. Returns URLs and metadata

    ```typescript import { v2 as cloudinary } from 'cloudinary'; import multer from 'multer';

    export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get('file') as File; const buffer = Buffer.from(await file.arrayBuffer());

    const result = await new Promise((resolve, reject) => { cloudinary.uploader.upload_stream( { folder: 'cms-uploads', resource_type: 'auto' }, (error, result) => error ? reject(error) : resolve(result) ).end(buffer); });

    return Response.json({ url: result.secure_url, publicId: result.public_id }); } ```

    Role-Based Access Control

    Clients need different permission levels:

    • Admin: Full access — manage users, settings, all pages
    • Editor: Create and edit pages, manage media
    • Viewer: Preview unpublished pages, read-only access

    ```typescript // middleware.ts const rolePermissions = { editor: ['page:read', 'page:create', 'page:update', 'media:upload'], admin: ['page:read', 'page:create', 'page:update', 'page:delete', 'media:upload', 'media:delete', 'users:manage'], };

    export async function middleware(req: NextRequest) { const session = await getSession(req); if (!session) return NextResponse.redirect('/login');

    const pathPermissions = getRequiredPermissions(req.nextUrl.pathname); const userRole = session.user.role; const hasPermission = pathPermissions.every( (p) => rolePermissions[userRole]?.includes(p) );

    if (!hasPermission) return NextResponse.redirect('/unauthorized'); } ```

    Preview Mode

    Clients need to see changes before publishing. The CMS generates preview links with a secret token:

    ``typescript export async function generatePreviewUrl(pageId: string) { const token = crypto.randomBytes(32).toString('hex'); await redis.set(preview:${pageId}, token, { ex: 3600 }); return /api/preview?page=${pageId}&token=${token}; } ``

    The public site's API route validates the token and serves the draft content.

    The Frontend API

    The public site fetches content from the CMS through API routes:

    ```typescript export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const slug = searchParams.get('slug');

    const page = await prisma.page.findUnique({ where: { slug }, include: { sections: { orderBy: { order: 'asc' } } }, });

    if (!page) return NextResponse.json(null, { status: 404 }); return NextResponse.json(page); } ```

    What Clients Actually Use

    After building several custom CMS platforms, I've learned that clients primarily use four features:

    1. Page builder drag-and-drop — weekly
    2. Media upload — monthly
    3. Text editing — weekly
    4. Preview — before every publish

    Features clients rarely touch: SEO settings (they rely on defaults), analytics (they use Google Analytics directly), and user management.

    Conclusion

    A custom headless CMS is a significant investment, but it pays off when non-technical clients need autonomy over their content. The key is to build exactly what they need — no more, no less — and make the editing experience feel as simple as updating a document. When done right, clients stop thinking of their website as a project and start thinking of it as a tool they own.

    Need a CMS tailored to your client's workflow? Let's build it together.

    ---

    R

    Written by

    Rahul

    Freelance developer for startups building SaaS products, MVPs, mobile apps, and conversion-focused website improvements.

    Building something?

    I am currently available for new projects. Share your idea and I will give you an honest assessment, delivery plan, and quote.