Claude
Skills
Sign in
Back

cms

Included with Lifetime
$97 forever

Simple CMS with posts, pages, categories, Tiptap rich text editor, and media management. Built on better-auth, drizzle, and storage skills. Use this skill when the user says "setup cms", "add cms", "content management", or "blog system".

Writing & Docs

What this skill does


# Content Management System

A lightweight CMS built on your existing stack: drizzle for the database, better-auth for authentication, the storage skill for file uploads, and Tiptap for rich text editing. Provides an admin UI at `/admin/content` for managing posts, pages, categories, and media.

## Quick Start

```bash
# 1. Install CMS dependencies
bun add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder react-hook-form @hookform/resolvers @tailwindcss/typography

# 2. Install shadcn components
bunx shadcn@latest add table badge input textarea label separator tabs card select

# 3. Push schema to database
bunx drizzle-kit push

# 4. Start dev server and open admin
bun dev
open http://localhost:3000/admin/content
```

## What Gets Created

### Database Schema

1. **`src/db/schema/cms.ts`** — Drizzle schema for posts, pages, categories, and join tables

### API Routes

1. **`src/app/api/cms/posts/route.ts`** — List and create posts
2. **`src/app/api/cms/posts/[id]/route.ts`** — Get, update, delete a post
3. **`src/app/api/cms/pages/route.ts`** — List and create pages
4. **`src/app/api/cms/pages/[id]/route.ts`** — Get, update, delete a page
5. **`src/app/api/cms/categories/route.ts`** — List, create, update, delete categories

### Components

1. **`src/components/cms/tiptap-editor.tsx`** — Rich text editor with toolbar
2. **`src/components/cms/media-picker.tsx`** — Image/video picker dialog using storage
3. **`src/components/cms/post-form.tsx`** — Post create/edit form
4. **`src/components/cms/page-form.tsx`** — Page create/edit form

### Admin Pages

1. **`src/app/admin/content/layout.tsx`** — Admin sidebar layout
2. **`src/app/admin/content/page.tsx`** — Content dashboard
3. **`src/app/admin/content/posts/page.tsx`** — Posts list
4. **`src/app/admin/content/posts/new/page.tsx`** — Create post
5. **`src/app/admin/content/posts/[id]/page.tsx`** — Edit post
6. **`src/app/admin/content/pages/page.tsx`** — Pages list
7. **`src/app/admin/content/pages/new/page.tsx`** — Create page
8. **`src/app/admin/content/pages/[id]/page.tsx`** — Edit page
9. **`src/app/admin/content/categories/page.tsx`** — Categories management
10. **`src/app/admin/content/media/page.tsx`** — Media management (wraps storage-ui)

### Modified Files

- **`src/db/index.ts`** — Add CMS schema import

## Prerequisites

Before using this skill:

1. **auth skill** completed — Provides `lib/auth-guard.ts` (`withAuth`, `withAdmin`), `db/index.ts`, `db/schema/auth.ts`, drizzle setup
2. **storage skill** completed — Provides `lib/storage/` and `/api/storage` endpoints
3. **storage-ui skill** completed — Provides `components/storage/` UI components
4. **shadcn/ui initialized** — With base components installed

## Project Structure Note

This skill uses `src/` prefix paths (standard Next.js with `--src-dir`).

**If your project uses `--no-src-dir`:**

- Replace `src/lib/` with `lib/`
- Replace `src/app/` with `app/`
- Replace `src/components/` with `components/`
- Replace `src/db/` with `db/`

## Installation

```bash
# Tiptap editor
bun add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/extension-placeholder

# Form handling
bun add react-hook-form @hookform/resolvers

# Tailwind typography (for prose styles in editor)
bun add @tailwindcss/typography

# shadcn components needed by CMS admin
bunx shadcn@latest add table badge input textarea label separator tabs card select
```

## Setup Steps

### 1. Create CMS Database Schema (`src/db/schema/cms.ts`)

```typescript
import {
  pgTable,
  text,
  timestamp,
  jsonb,
  primaryKey,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { user } from "./auth";

export const categories = pgTable("cms_categories", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  description: text("description"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const posts = pgTable("cms_posts", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: jsonb("content"),
  excerpt: text("excerpt"),
  featuredMediaUrl: text("featured_media_url"),
  featuredMediaKey: text("featured_media_key"),
  featuredMediaType: text("featured_media_type"),
  status: text("status").notNull().default("draft"),
  authorId: text("author_id")
    .notNull()
    .references(() => user.id),
  publishedAt: timestamp("published_at"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const pages = pgTable("cms_pages", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: jsonb("content"),
  metaTitle: text("meta_title"),
  metaDescription: text("meta_description"),
  metaImageUrl: text("meta_image_url"),
  metaImageKey: text("meta_image_key"),
  status: text("status").notNull().default("draft"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const postsToCategories = pgTable(
  "cms_posts_to_categories",
  {
    postId: text("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    categoryId: text("category_id")
      .notNull()
      .references(() => categories.id, { onDelete: "cascade" }),
  },
  (t) => [primaryKey({ columns: [t.postId, t.categoryId] })]
);

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(user, { fields: [posts.authorId], references: [user.id] }),
  postCategories: many(postsToCategories),
}));

export const categoriesRelations = relations(categories, ({ many }) => ({
  categoryPosts: many(postsToCategories),
}));

export const postsToCategoriesRelations = relations(
  postsToCategories,
  ({ one }) => ({
    post: one(posts, {
      fields: [postsToCategories.postId],
      references: [posts.id],
    }),
    category: one(categories, {
      fields: [postsToCategories.categoryId],
      references: [categories.id],
    }),
  })
);
```

### 2. Update Database Client (`src/db/index.ts`)

Add the CMS schema import alongside the existing auth schema:

```typescript
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as authSchema from "./schema/auth";
import * as cmsSchema from "./schema/cms";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set");

export const client = postgres(connectionString, { prepare: false });
export const db = drizzle(client, {
  schema: { ...authSchema, ...cmsSchema },
});
```

### 3. Add Tailwind Typography Plugin

Add to your `src/app/globals.css` (after the existing `@import "tailwindcss";`):

```css
@plugin "@tailwindcss/typography";
```

### 4. Create Posts API Routes

**`src/app/api/cms/posts/route.ts`**:

```typescript
import { NextRequest, NextResponse } from "next/server";
import { withAdmin } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import { posts, postsToCategories, categories } from "@/lib/db/schema/cms";
import { desc, eq, ilike, sql, and, inArray } from "drizzle-orm";

export const GET = withAdmin(async (request) => {
  const url = request.nextUrl;
  const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
  const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? "20")));
  const status = url.searchParams.get("status");
  const search = url.searchParams.get("search");
  const offset = (page - 1) * limit;

  const conditions = [];
  if (status) conditions.push(eq(posts.status, status));
  if (sea
Files: 1
Size: 72.5 KB
Complexity: 39/100
Category: Writing & Docs

Related in Writing & Docs