ProgrUmar Logo
Module 11: Capstone — Full-stack Blog Platform

Building the CMS Dashboard

Duration: 35 mins

Admin Dashboard

Protect the /admin route group with Auth.js middleware. Build a post editor using a headless rich-text editor (Tiptap or Lexical), and handle image uploads to Cloudinary or an S3-compatible bucket via a Server Action.

Building the CMS Dashboard

A content management platform is useless without a dashboard that allows writers to edit draft layouts, upload hero banner images, and publish articles. In this lesson, we will construct the backend admin panel of our Capstone Project. We will implement Auth.js authentication middleware gates on dashboard routes, embed a Tiptap headless rich-text editor component, write a Server Action to handle base64 image uploads to Cloudinary/S3, and integrate database save routines with Zod validation.


1. Protecting CMS Routes with Auth.js Middleware

To prevent non-admin visitors from accessing dashboard controllers, configure route protection filters inside your middleware file.

Let's define a match filter routing users back to the sign-in page if they are not authenticated admins:

// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAdmin = req.auth?.user?.role === 'ADMIN';
  const isAdminRoute = req.nextUrl.pathname.startsWith('/admin');

  if (isAdminRoute) {
    if (!isLoggedIn) {
      return NextResponse.redirect(new URL('/login', req.nextUrl));
    }
    if (!isAdmin) {
      return NextResponse.redirect(new URL('/', req.nextUrl)); // Send normal users back home
    }
  }

  return NextResponse.next();
});

export const config = {
  // Apply middleware across all admin paths
  matcher: ['/admin/:path*'],
};

2. Integrating Tiptap Rich-Text Editor

Traditional textareas are insufficient for writing formatted blog articles. Headless rich-text editors (like Tiptap) render structured HTML while letting you design the visual toolbar yourself.

Install Tiptap packages in your workspace:

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit

Create a Client Component text editor wrapper:

// components/RichEditor.tsx
'use client';

import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';

interface RichEditorProps {
  content: string;
  onChange: (html: string) => void;
}

export function RichEditor({ content, onChange }: RichEditorProps) {
  const editor = useEditor({
    extensions: [StarterKit],
    content: content,
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML()); // Sync HTML output back to parent state
    },
  });

  if (!editor) return <div className="animate-pulse bg-neutral-100 h-40" />;

  return (
    <div className="border rounded-md overflow-hidden">
      {/* Editor Menu Toolbar */}
      <div className="bg-neutral-50 p-2 border-b flex gap-2">
        <button
          type="button"
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'font-bold text-blue-600' : ''}
        >
          Bold
        </button>
        <button
          type="button"
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'italic text-blue-600' : ''}
        >
          Italic
        </button>
      </div>
      
      {/* Active Editor Canvas Area */}
      <EditorContent editor={editor} className="p-4 min-h-[200px] outline-none" />
    </div>
  );
}

3. Handling Image Uploads using Server Actions

Writers must be able to upload images for hero banners or inline post bodies. Because Server Actions are Node.js modules, we can write an upload handler that takes file buffers and sends them to Cloudinary or AWS S3:

// app/actions/upload.ts
'use server';

import { v2 as cloudinary } from 'cloudinary';

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

export async function uploadImage(base64Image: string): Promise<{ url?: string; error?: string }> {
  try {
    const uploadResponse = await cloudinary.uploader.upload(base64Image, {
      folder: 'progrumar-blog',
    });
    
    return { url: uploadResponse.secure_url };
  } catch (error: any) {
    console.error('Cloudinary upload crash:', error.message);
    return { error: 'Failed to upload image' };
  }
}

4. Processing File Selection Inputs inside the Client

Inside your Client Component form, convert file inputs into base64 format strings and execute the Server Action to retrieve the uploaded URL:

// components/ImageUploader.tsx
'use client';

import { useState } from 'react';
import { uploadImage } from '@/app/actions/upload';

export function ImageUploader({ onUploadComplete }: { onUploadComplete: (url: string) => void }) {
  const [uploading, setUploading] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    const reader = new FileReader();
    
    reader.onloadend = async () => {
      const base64String = reader.result as string;
      const res = await uploadImage(base64String);
      
      if (res.url) {
        onUploadComplete(res.url);
      } else {
        alert('Upload failed: ' + res.error);
      }
      setUploading(false);
    };

    reader.readAsDataURL(file); // Convert raw file to base64 string
  };

  return (
    <div className="p-4 border border-dashed rounded-md text-center">
      <input type="file" accept="image/*" onChange={handleFileChange} disabled={uploading} />
      {uploading && <p className="text-sm text-neutral-500 mt-2">Uploading image to cloud store...</p>}
    </div>
  );
}

5. Implementing the Database Write Server Action

Once our title, content body, and banner URL are collected, write a Server Action to validate inputs with Zod and save the post.

CRITICAL STEP: You must call revalidatePath() to clear cached public blog layout feeds so users see new articles instantly without waiting for static cache timeouts:

// app/actions/posts.ts
'use server';

import { db } from '@/db';
import { posts } from '@/db/schema';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';

const createPostSchema = z.object({
  title: z.string().min(5, { message: 'Title must be at least 5 characters' }),
  slug: z.string().regex(/^[a-z0-9-]+$/, { message: 'Slug must contain only letters, numbers, and dashes' }),
  content: z.string().min(20, { message: 'Content must be at least 20 characters' }),
});

export async function createPost(rawData: any) {
  // Confirm user has administrative rights
  const session = await auth();
  if (!session || session.user?.role !== 'ADMIN') {
    return { error: 'Unauthorized credentials.' };
  }

  const result = createPostSchema.safeParse(rawData);
  if (!result.success) {
    return { error: 'Validation failed', details: result.error.format() };
  }

  const { title, slug, content } = result.data;

  try {
    await db.insert(posts).values({
      title,
      slug,
      content,
      authorId: Number(session.user.id), // Bind currently logged-in author
    });
  } catch (err: any) {
    return { error: 'Database write failed. Slug might already exist.' };
  }

  // Clear page caches for public blog feeds
  revalidatePath('/');
  revalidatePath('/posts/' + slug);
  
  redirect('/admin'); // Return author to list dashboard
}

6. Common Gotchas

  • Memory Leaks from useEditor: Inside Client Components, calling useEditor repeatedly without cleanup handlers leaks CPU resources. Ensure your editor component relies on React lifecycle mounts to instantiate the editor instance exactly once per component lifetime.
  • Missing revalidatePath Calls: If you omit calling revalidatePath('/') inside your save Server Action, Vercel will continue serving the old build sitemap list. Users will not see new articles until you trigger a redeployment.

Key Takeaways

  • Secure CMS routes using Auth.js role validation inside the Next.js middleware router.
  • Leverage Tiptap for rendering structured HTML fields in forms.
  • Process binary file uploads inside Server Actions by converting files to base64 strings in the browser.
  • Validate inputs with Zod and assign records to active session authors.
  • Call revalidatePath() inside mutations to flush public static route page caches instantly.

Creating the CMS admin panel handles post writes. But readers must be able to view posts with high-quality SEO tags. In the next lesson, we will cover Public Blog with Dynamic OG Images & Full SEO, building dynamic Opengraph banners, Article JSON-LD, and auto-generated XML sitemaps.

Chat with us