How to Build CRUD with Antigravity (Next.js + Prisma)
Master CRUD operations with Antigravity. Build a complete blog CMS with Prisma, rich text editing, validation, and many-to-many relations.

How to Build CRUD with Antigravity (Next.js + Prisma)
Overview
Master CRUD (Create, Read, Update, Delete) operations with Antigravity by building a complete blog CMS in 20 minutes. This tutorial covers database models, API endpoints, forms, validation, and a polished UI.
What is CRUD?
CRUD represents the four basic operations for persistent storage:
- Create: Add new records to the database
- Read: Retrieve and display records
- Update: Modify existing records
- Delete: Remove records
Every data-driven application uses CRUD operations.
What You'll Build
A blog content management system featuring:
- Post list with search and filters
- Create post with rich text editor
- Edit post with draft saving
- Delete post with confirmation
- Category management (full CRUD)
- Tag system with many-to-many relations
- Image uploads for featured images
- SEO fields (meta title, description)
- Slug generation from titles
Prerequisites
- Antigravity editor access
- Basic understanding of databases
- (Optional) PostgreSQL database
Tech Stack
- Next.js 14 with App Router and Server Actions
- TypeScript for type safety
- Prisma ORM with PostgreSQL
- TipTap rich text editor
- UploadThing for image uploads
- React Hook Form + Zod validation
- shadcn/ui components
Step 1: Create Your Project
Open Antigravity and start a new workspace:
Project name: blog-cms
Location: ~/projects/blog-cms
Step 2: Submit the Complete Prompt
Build a blog CMS with full CRUD functionality for posts, categories, and tags.
POST CRUD:
Create:
- Rich text editor (TipTap) with formatting:
- Bold, italic, underline, strikethrough
- Headings (H1-H6)
- Bullet and numbered lists
- Links and images
- Code blocks
- Blockquotes
- Form fields:
- Title (required, max 200 chars)
- Slug (auto-generated from title, editable)
- Excerpt (optional, max 300 chars)
- Content (rich text, required)
- Featured image (upload, required for published)
- Categories (multi-select dropdown)
- Tags (create new or select existing)
- SEO title (optional, default to title)
- SEO description (optional, max 160 chars)
- Status (draft, published, scheduled)
- Publish date (for scheduled posts)
- Draft auto-save every 30 seconds
- Preview mode
Read:
- Post list page:
- Card grid layout (3 columns desktop)
- Featured image thumbnail
- Title, excerpt, author
- Categories as badges
- Created/published date
- Status indicator
- Actions (View, Edit, Delete)
- Filters:
- Search by title or content
- Filter by category
- Filter by status (all, draft, published)
- Filter by date range
- Sort by date, title, views
- Pagination (12 posts per page)
Update:
- Edit form pre-filled with existing data
- Save as draft or publish
- Update slug (with warning)
- Modified date tracking
- Revision history (last 5 versions)
Delete:
- Confirmation modal with post title
- Soft delete (mark as deleted, not remove)
- Restore from trash option
- Permanent delete after 30 days
CATEGORY CRUD:
- Name (required, unique)
- Slug (auto-generated)
- Description (optional)
- Parent category (for hierarchical)
- Post count (automatic)
- Actions: Create, Edit, Delete (if no posts)
TAG CRUD:
- Name (required)
- Slug (auto-generated)
- Post count (automatic)
- Autocomplete when typing
- Create inline from post form
TECH STACK:
- Next.js 14 with App Router
- Server Actions (not API routes)
- TypeScript
- Prisma + PostgreSQL
- TipTap editor (@tiptap/react)
- UploadThing for images
- React Hook Form + Zod validation
- shadcn/ui components
- Tailwind CSS
- date-fns for dates
DATA MODELS:
Post {
id: String @id @default(uuid())
title: String
slug: String @unique
excerpt: String?
content: String @db.Text
featuredImage: String?
seoTitle: String?
seoDescription: String?
status: PostStatus @default(DRAFT)
publishedAt: DateTime?
authorId: String
categories: Category[]
tags: Tag[]
createdAt: DateTime @default(now())
updatedAt: DateTime @updatedAt
deletedAt: DateTime?
}
Category {
id: String @id @default(uuid())
name: String @unique
slug: String @unique
description: String?
parentId: String?
parent: Category? @relation("SubCategory")
children: Category[] @relation("SubCategory")
posts: Post[]
createdAt: DateTime
}
Tag {
id: String @id @default(uuid())
name: String @unique
slug: String @unique
posts: Post[]
createdAt: DateTime
}
enum PostStatus {
DRAFT
PUBLISHED
SCHEDULED
}
SERVER ACTIONS:
- createPost(data: PostInput)
- updatePost(id: string, data: PostInput)
- deletePost(id: string)
- restorePost(id: string)
- getPosts(filters: PostFilters)
- getPostById(id: string)
- createCategory(data: CategoryInput)
- updateCategory(id: string, data: CategoryInput)
- deleteCategory(id: string)
- createTag(data: TagInput)
- deleteTag(id: string)
- saveDraft(id: string, data: Partial<PostInput>)
VALIDATION:
Title:
- Required
- Min 10 chars, max 200 chars
- Unique slug generated
Slug:
- Lowercase
- No spaces (replace with hyphens)
- No special chars except hyphens
- Unique check
Content:
- Required for published posts
- Min 100 chars
Featured Image:
- Required for published posts
- Max 5MB
- Formats: jpg, png, webp
Categories:
- At least one required for published posts
SEO Description:
- Max 160 chars
- Character counter
FEATURES:
- Real-time slug preview
- Duplicate post functionality
- Bulk actions (delete, change status)
- Export posts to JSON/CSV
- Import from CSV
- Post templates
- Keyboard shortcuts (Cmd+S to save)
PAGES:
- /admin/posts (list)
- /admin/posts/new (create)
- /admin/posts/[id]/edit (update)
- /admin/categories (manage)
- /admin/tags (manage)
DESIGN:
- Professional CMS interface
- Loading states for all actions
- Toast notifications for success/error
- Unsaved changes warning
- Responsive mobile design
- Dark mode support
Step 3: Review Implementation Plan
Antigravity shows the plan with:
Database Schema
// prisma/schema.prisma
model Post {
id String @id @default(uuid())
title String
slug String @unique
excerpt String?
content String @db.Text
featuredImage String?
seoTitle String?
seoDescription String?
status PostStatus @default(DRAFT)
publishedAt DateTime?
authorId String
categories Category[]
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}
model Category {
id String @id @default(uuid())
name String @unique
slug String @unique
description String?
parentId String?
parent Category? @relation("SubCategory", fields: [parentId], references: [id])
children Category[] @relation("SubCategory")
posts Post[]
createdAt DateTime @default(now())
}
model Tag {
id String @id @default(uuid())
name String @unique
slug String @unique
posts Post[]
createdAt DateTime @default(now())
}
enum PostStatus {
DRAFT
PUBLISHED
SCHEDULED
}
Server Actions
// app/actions/posts.ts
'use server'
export async function createPost(data: PostInput) {
const validated = PostSchema.parse(data);
const post = await prisma.post.create({
data: {
...validated,
slug: slugify(validated.title),
categories: {
connect: validated.categoryIds.map(id => ({ id }))
},
tags: {
connectOrCreate: validated.tags.map(name => ({
where: { name },
create: { name, slug: slugify(name) }
}))
}
}
});
revalidatePath('/admin/posts');
return post;
}
Key Components
- PostEditor.tsx: TipTap rich text editor
- PostForm.tsx: Main form with all fields
- PostList.tsx: Grid of post cards
- PostFilters.tsx: Search and filter controls
- CategoryManager.tsx: Category CRUD interface
- TagInput.tsx: Autocomplete tag selector
- ImageUpload.tsx: Featured image uploader
- SlugInput.tsx: Auto-generate from title
Review and Click "Proceed"
Step 4: Watch Implementation
Task Group 1: Database Models (2 min)
- Create Prisma schema with relations
- Generate Prisma client
- Create migration
Task Group 2: Server Actions (4 min)
- Post CRUD actions
- Category CRUD actions
- Tag CRUD actions
- Validation schemas with Zod
Task Group 3: Post Editor (5 min)
- TipTap editor setup
- Formatting toolbar
- Image upload integration
- Content state management
Task Group 4: Post Form (5 min)
- Form layout with React Hook Form
- Field validation
- Draft auto-save
- Preview mode
Task Group 5: Post List (4 min)
- Grid layout with cards
- Search and filters
- Pagination
- Bulk actions
Task Group 6: Category & Tag Management (3 min)
- Category list and forms
- Tag management
- Post count displays
Step 5: Database Setup
Run Migrations
npx prisma migrate dev --name init
npx prisma generate
Seed Sample Data
npx prisma db seed
Creates:
- 3 categories
- 10 tags
- 20 sample posts
Step 6: Test CRUD Operations
Create (POST)
- Navigate to
/admin/posts/new - Fill in the form:
Title: "My First Blog Post" Excerpt: "A short introduction to the post" Content: [Use rich text editor] Featured Image: [Upload image] Categories: Select 1-2 Tags: Type and create new tags Status: Draft - Click "Save Draft"
- Verify toast notification appears
- Check auto-saved slug:
my-first-blog-post
Test Cases:
- [ ] Title validation (required, length)
- [ ] Slug auto-generation
- [ ] Slug uniqueness check
- [ ] Rich text formatting (bold, links, etc.)
- [ ] Image upload works
- [ ] Category multi-select
- [ ] Tag creation and selection
- [ ] Draft auto-save (wait 30s)
- [ ] Preview mode shows formatted content
Read (GET)
- Navigate to
/admin/posts - View post list
Test Cases:
- [ ] All posts display with thumbnails
- [ ] Title, excerpt, categories shown
- [ ] Status badges display correctly
- [ ] Created date formats properly
- [ ] Search by title works
- [ ] Filter by category works
- [ ] Filter by status works
- [ ] Sort by date/title works
- [ ] Pagination controls function
- [ ] Click post opens detail view
Update (PUT)
- Click "Edit" on a post
- Modify:
Title: "Updated Title" Content: Add more text Status: Published - Click "Save & Publish"
Test Cases:
- [ ] Form pre-fills with existing data
- [ ] Rich text editor loads content correctly
- [ ] Slug updates when title changes
- [ ] Warning shown for slug change
- [ ] Categories pre-selected
- [ ] Tags pre-populated
- [ ] Published date auto-sets
- [ ] "Save as Draft" vs "Publish" buttons
- [ ] Unsaved changes warning on exit
- [ ] Updated date changes
Delete (DELETE)
- Click "Delete" on a post
- Confirmation modal appears
- Type post title to confirm
- Click "Delete Permanently"
Test Cases:
- [ ] Confirmation modal shows post title
- [ ] Cancel button aborts
- [ ] Soft delete (sets deletedAt)
- [ ] Post moves to trash
- [ ] Restore from trash works
- [ ] Permanent delete after confirmation
- [ ] Category/tag relations cleaned up
- [ ] Featured image removed from storage
Category CRUD
Create:
- Go to
/admin/categories - Click "New Category"
- Fill in:
Name: "Technology" Description: "Tech-related posts" Parent: None - Click "Create"
Update:
- Click "Edit" on category
- Change description
- Save
Delete:
- Click "Delete"
- Warning if category has posts
- Confirm deletion
Test Cases:
- [ ] Create category works
- [ ] Slug auto-generates
- [ ] Hierarchical categories (parent/child)
- [ ] Post count updates automatically
- [ ] Edit category works
- [ ] Delete blocked if posts exist
- [ ] Delete with confirmation if empty
Tag CRUD
Create:
- From post form, type new tag name
- Press Enter
- Tag created and added
Delete:
- Go to
/admin/tags - Click "Delete" on tag
- Confirm
Test Cases:
- [ ] Inline tag creation from post form
- [ ] Tag autocomplete suggests existing tags
- [ ] Tag appears in post relation
- [ ] Delete tag works
- [ ] Tag removal from post updates relations
Common CRUD Patterns Explained
Many-to-Many Relations
Posts and categories have a many-to-many relationship:
// Connect existing categories
await prisma.post.update({
where: { id: postId },
data: {
categories: {
set: [], // Clear existing
connect: categoryIds.map(id => ({ id })) // Add new
}
}
});
ConnectOrCreate for Tags
Create tags on-the-fly if they don't exist:
tags: {
connectOrCreate: tagNames.map(name => ({
where: { name },
create: { name, slug: slugify(name) }
}))
}
Soft Delete
Mark as deleted instead of removing:
await prisma.post.update({
where: { id },
data: { deletedAt: new Date() }
});
// Exclude soft-deleted in queries
const posts = await prisma.post.findMany({
where: { deletedAt: null }
});
Slug Generation
Auto-generate URL-friendly slugs:
import slugify from 'slugify';
const slug = slugify(title, {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g
});
// Ensure uniqueness
let uniqueSlug = slug;
let counter = 1;
while (await prisma.post.findUnique({ where: { slug: uniqueSlug } })) {
uniqueSlug = `${slug}-${counter}`;
counter++;
}
Draft Auto-Save
Save drafts automatically:
// components/PostForm.tsx
useEffect(() => {
const timer = setTimeout(() => {
if (isDirty && status === 'DRAFT') {
saveDraft(postId, getValues());
}
}, 30000); // 30 seconds
return () => clearTimeout(timer);
}, [formValues]);
Validation with Zod
Post Schema
import { z } from 'zod';
export const PostSchema = z.object({
title: z.string()
.min(10, 'Title must be at least 10 characters')
.max(200, 'Title must not exceed 200 characters'),
slug: z.string()
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase with hyphens only'),
excerpt: z.string()
.max(300, 'Excerpt must not exceed 300 characters')
.optional(),
content: z.string()
.min(100, 'Content must be at least 100 characters'),
featuredImage: z.string().url().optional(),
categoryIds: z.array(z.string()).min(1, 'Select at least one category'),
tags: z.array(z.string()),
seoTitle: z.string().max(60).optional(),
seoDescription: z.string().max(160).optional(),
status: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']),
publishedAt: z.date().optional(),
}).refine((data) => {
// Published posts require featured image
if (data.status === 'PUBLISHED' && !data.featuredImage) {
return false;
}
return true;
}, {
message: 'Published posts require a featured image',
path: ['featuredImage'],
});
Category Schema
export const CategorySchema = z.object({
name: z.string().min(2).max(50),
slug: z.string().regex(/^[a-z0-9-]+$/),
description: z.string().max(200).optional(),
parentId: z.string().uuid().optional(),
});
Error Handling
Server Action Error Handling
export async function createPost(data: PostInput) {
try {
const validated = PostSchema.parse(data);
const post = await prisma.post.create({
data: validated,
});
revalidatePath('/admin/posts');
return { success: true, data: post };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Validation failed',
issues: error.errors
};
}
if (error.code === 'P2002') {
return {
success: false,
error: 'A post with this slug already exists'
};
}
return {
success: false,
error: 'Failed to create post'
};
}
}
Client-Side Error Display
// components/PostForm.tsx
const onSubmit = async (data) => {
const result = await createPost(data);
if (result.success) {
toast.success('Post created successfully!');
router.push('/admin/posts');
} else {
toast.error(result.error);
if (result.issues) {
result.issues.forEach(issue => {
setError(issue.path[0], {
message: issue.message
});
});
}
}
};
Advanced CRUD Features
Bulk Operations
Select multiple posts and perform actions:
export async function bulkDeletePosts(ids: string[]) {
await prisma.post.updateMany({
where: { id: { in: ids } },
data: { deletedAt: new Date() }
});
revalidatePath('/admin/posts');
}
export async function bulkUpdateStatus(
ids: string[],
status: PostStatus
) {
await prisma.post.updateMany({
where: { id: { in: ids } },
data: { status }
});
revalidatePath('/admin/posts');
}
Duplicate Post
Create a copy of an existing post:
export async function duplicatePost(id: string) {
const original = await prisma.post.findUnique({
where: { id },
include: { categories: true, tags: true }
});
const duplicate = await prisma.post.create({
data: {
...original,
id: undefined,
title: `${original.title} (Copy)`,
slug: `${original.slug}-copy-${Date.now()}`,
status: 'DRAFT',
publishedAt: null,
categories: {
connect: original.categories.map(c => ({ id: c.id }))
},
tags: {
connect: original.tags.map(t => ({ id: t.id }))
}
}
});
return duplicate;
}
Revision History
Track post changes:
model PostRevision {
id String @id @default(uuid())
postId String
post Post @relation(fields: [postId], references: [id])
content String @db.Text
title String
createdAt DateTime @default(now())
}
// Save revision on update
export async function updatePost(id: string, data: PostInput) {
// Save current version as revision
const current = await prisma.post.findUnique({ where: { id } });
await prisma.postRevision.create({
data: {
postId: id,
title: current.title,
content: current.content,
}
});
// Update post
const updated = await prisma.post.update({
where: { id },
data,
});
return updated;
}
Optimization Tips
Pagination Performance
// Use cursor-based pagination for large datasets
export async function getPosts(cursor?: string) {
const posts = await prisma.post.findMany({
take: 12,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
return {
posts,
nextCursor: posts[posts.length - 1]?.id,
};
}
Search Optimization
// Full-text search with Prisma
const posts = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
{ excerpt: { contains: query, mode: 'insensitive' } },
],
},
});
Image Optimization
import Image from 'next/image';
<Image
src={post.featuredImage}
alt={post.title}
width={800}
height={600}
placeholder="blur"
blurDataURL={post.blurDataUrl}
/>
Troubleshooting
Prisma Relation Errors
Issue: "Foreign key constraint failed"
Solution: Ensure related records exist before connecting:
// Check category exists
const category = await prisma.category.findUnique({
where: { id: categoryId }
});
if (!category) {
throw new Error('Category not found');
}
Unique Constraint Violations
Issue: "Unique constraint failed on slug"
Solution: Implement slug uniqueness check:
let slug = slugify(title);
let counter = 1;
while (await prisma.post.findUnique({ where: { slug } })) {
slug = `${slugify(title)}-${counter}`;
counter++;
}
Form Not Submitting
Issue: React Hook Form validation fails silently
Solution: Check validation errors:
const { formState: { errors } } = useForm();
console.log('Validation errors:', errors);
// Display all errors
Object.entries(errors).forEach(([field, error]) => {
toast.error(`${field}: ${error.message}`);
});
Next Steps
You've mastered CRUD with Antigravity! Now explore:
- Extend Your Schema: How to Extend Prisma Schema
- Build APIs: How to Create API Routes with Antigravity
- Add Auth: Authentication System Example
- Best Practices: Antigravity Best Practices
Conclusion
In 20 minutes, you've built a production-ready blog CMS with:
- Complete CRUD for posts, categories, and tags
- Rich text editing
- Image uploads
- Validation and error handling
- Draft auto-save
- Many-to-many relations
Antigravity handled all implementation while you focused on requirements.
Try more examples: Antigravity Projects Examples
Keywords: antigravity crud example, antigravity prisma, antigravity nextjs example, build crud antigravity, blog cms antigravity, prisma crud tutorial, nextjs crud app