Building in Public#building-in-public

Building a Custom CMS in Next.js From Scratch

By Mohamed Abdi Guled

ShabelleHub needed a CMS — instead of a headless platform, a minimal custom admin panel was built directly against Supabase.

Building a Custom CMS in Next.js From Scratch

ShabelleHub needed a CMS. The options were a headless CMS (Contentful, Sanity, Strapi), a database with a generic admin UI (Supabase Studio), or a custom-built admin panel inside the Next.js app itself.

We built it ourselves. This article explains why and what that actually involved.


Why Not a Headless CMS

Headless CMS platforms solve the content editing problem well. They provide a polished UI, content modelling tools, rich text editors, media management, and webhooks for triggering rebuilds. For a team of editors who are not developers, a headless CMS is usually the right choice.

ShabelleHub's situation was different. The site is built and maintained by a solo developer on a mobile device. The content structure — blog posts, tool reviews, categories, tags, authors — is specific enough that a generic CMS would need significant configuration to match it. And the content editing happens infrequently enough that paying $99/month for a polished headless CMS was not justified at this stage.

Building the admin panel inside Next.js meant the editor interface could be exactly what the content structure required, deployed automatically alongside the site, and accessible from the same domain.


The Architecture

The CMS is a set of pages under pages/admin/ — protected by authentication middleware — that read and write to Supabase directly through API routes.

pages/admin/
  login.js              — unauthenticated entry point
  index.js              — dashboard (redirect target after login)
  posts/
    index.js            — post list with filters and search
    [id].js             — post editor
  tools/
    index.js            — tool list
    [id].js             — tool editor
  categories/index.js   — category management
  tags/index.js         — tag management
  authors/index.js      — author management
  media/index.js        — file upload and media library
  users.js              — user role management (admin only)
  settings.js           — site-wide settings
  blog-seo/index.js     — blog SEO defaults

Each page follows the same pattern: fetch data on mount, display it in a table or form, provide actions (create, edit, delete, publish, unpublish) that call API routes, show loading and error states throughout.


Authentication and Role-Based Access

The CMS uses Supabase Auth for authentication and a profiles table for role management:

CREATE TABLE profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id),
  email TEXT NOT NULL,
  name TEXT,
  role TEXT NOT NULL DEFAULT 'editor'
    CHECK (role IN ('editor', 'admin')),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

New signups default to editor. The admin role can only be assigned by an existing admin through the Users page — there is no self-service promotion. This is enforced server-side in every API route:

// pages/api/admin/users/[id]/role.js
export default async function handler(req, res) {
  const supabase = createAdminClient();

  // Verify the requesting user is an admin
  const { data: { user } } = await supabase.auth.getUser(
    req.headers.authorization?.replace('Bearer ', '')
  );

  const { data: profile } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single();

  if (profile?.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }

  // Proceed with role update
}

The client-side check in AdminLayout (which wraps every admin page) redirects non-authenticated users to /admin/login. But the actual enforcement happens in the API routes — client-side protection is UX, not security.


The Post Editor

The post editor is the most complex part of the CMS. It needed to handle:

  • Title and slug editing (with auto-generation from title)
  • Long-form content with formatting
  • SEO fields (meta title, meta description, canonical URL)
  • Featured image selection from the media library
  • Author, category, and tag assignment
  • Publish/unpublish/draft/delete actions
  • FAQ schema management

Rather than integrate a rich text editor library (Slate, TipTap, Quill), we built a simple block editor using <textarea> elements for each content block. The content is stored as an array of block objects:

// Block structure
[
  { type: 'paragraph', content: 'First paragraph text' },
  { type: 'code', language: 'js', content: 'const x = 1;' },
  { type: 'heading', level: 2, content: 'Section heading' },
  { type: 'image', src: 'https://...', alt: 'Description' },
]

Each block renders as a different editing component. Code blocks get a monospace textarea with syntax highlighting display. Heading blocks show the rendered size. Image blocks show a preview alongside the URL input.

This is not as polished as TipTap. It is significantly easier to maintain — no version conflicts, no migration required when the library updates, no bundle size impact. For content that is primarily text and code blocks, it covers the editing needs.


The Media Library

The media library uses Supabase Storage. Uploads go through an API route that enforces file type and size restrictions before passing to storage:

// pages/api/admin/media/upload.js
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

export default async function handler(req, res) {
  const { type, size, name, base64 } = req.body;

  if (!ALLOWED_TYPES.includes(type)) {
    return res.status(400).json({ error: 'File type not allowed' });
  }
  if (size > MAX_SIZE_BYTES) {
    return res.status(400).json({ error: 'File exceeds 5MB limit' });
  }

  const buffer = Buffer.from(base64, 'base64');
  const path = `media/${Date.now()}-${name.replace(/[^a-z0-9.]/gi, '-')}`;

  const { data, error } = await supabase.storage
    .from('shabellehub')
    .upload(path, buffer, { contentType: type });

  if (error) return res.status(500).json({ error: error.message });

  const { data: { publicUrl } } = supabase.storage
    .from('shabellehub')
    .getPublicUrl(path);

  return res.status(200).json({ url: publicUrl });
}

File names are sanitised before storage — [^a-z0-9.] replaces any non-alphanumeric character with a hyphen. This prevents path traversal and unusual characters in storage keys.


What Building It Yourself Costs

Building a custom CMS is not free. The time that went into the post editor, media library, user management, and role enforcement is time that did not go into content, marketing, or new features.

The honest accounting:

What you get: An admin interface that exactly matches your content model. No feature you do not need. No configuration to fight. No vendor lock-in. No monthly cost.

What it costs: Development time. Ongoing maintenance when requirements change. Edge cases you did not anticipate (drag-to-reorder blocks, bulk publishing, scheduled posts) that require more development time.

For ShabelleHub at its current scale, the custom CMS was the right trade-off. The content model is specific, the team is one person, and the features needed were clear and bounded.

For a site with multiple editors, non-technical content creators, or complex content workflows, a headless CMS would be worth the cost. The decision depends on who will use the CMS and how often, not on which approach is technically more interesting.


*This article is part of the ShabelleHub Building in Public series.*

*Next: Supabase Row Level Security: A Practical Setup Guide for Next.js*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog