agprojects
      Tutorial
      {"antigravity"
      "crud"
      "prisma"
      "nextjs"
      "database"}

      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.

      By Projects.tech TeamNovember 20, 202520 min read25 views
      How to Build CRUD with Antigravity (Next.js + Prisma)

      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)

      1. Navigate to /admin/posts/new
      2. 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
        
      3. Click "Save Draft"
      4. Verify toast notification appears
      5. 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)

      1. Navigate to /admin/posts
      2. 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)

      1. Click "Edit" on a post
      2. Modify:
        Title: "Updated Title"
        Content: Add more text
        Status: Published
        
      3. 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)

      1. Click "Delete" on a post
      2. Confirmation modal appears
      3. Type post title to confirm
      4. 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:

      1. Go to /admin/categories
      2. Click "New Category"
      3. Fill in:
        Name: "Technology"
        Description: "Tech-related posts"
        Parent: None
        
      4. Click "Create"

      Update:

      1. Click "Edit" on category
      2. Change description
      3. Save

      Delete:

      1. Click "Delete"
      2. Warning if category has posts
      3. 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:

      1. From post form, type new tag name
      2. Press Enter
      3. Tag created and added

      Delete:

      1. Go to /admin/tags
      2. Click "Delete" on tag
      3. 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:

      1. Extend Your Schema: How to Extend Prisma Schema
      2. Build APIs: How to Create API Routes with Antigravity
      3. Add Auth: Authentication System Example
      4. 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

      Related Articles

      Tutorial

      Vibe Coding Sora AI: Copilot, Cursor & Prompt Engineering Tutorial

      1. Introduction: What Is Vibe Coding and Why It Matters 1.1 Defining "Vibe Coding" — From Natural Language to Working Software The term "vibe coding" was popula...

      October 28, 202610 min
      Tutorial

      Vibe Coding Tutorial: Sora AI, Copilot, Cursor & Prompt Engineering

      Introduction: Why Vibe Coding Is Changing How We Build Software Something fundamental has shifted in how developers write software. Instead of typing every line...

      October 21, 20269 min
      Tutorial

      AI Coding Tutorial: Vibe Coding with Copilot, Cursor & Google Antigravity

      Introduction: Why Vibe Coding Is Changing How We Build Software "Vibe coding" is the practice of describing what you want in plain natural language and letting ...

      September 30, 20268 min