Welcome to Part 2 of the series. The Quick Builds are done. Now we build real applications — the kind that take a full afternoon and have multiple moving parts.

First up: a full-stack blog platform. Not a static site. A real blog with a database, admin panel, markdown editor, authentication, and SEO features. The kind of project that would normally take a weekend.

Total time: 3 hours 22 minutes. 7 prompts. A lot of debugging auth.

What We’re Building

A complete blog platform with:

  • Public blog with post listing, pagination, and individual post pages
  • Admin dashboard behind authentication
  • Markdown editor with live preview for creating and editing posts
  • Categories and tags for organizing content
  • SEO meta tags per post (title, description, Open Graph)
  • Full-text search on the public blog
  • RSS feed at /feed.xml
  • Sitemap generation

Tech stack: Next.js 15 (App Router), PostgreSQL (Supabase), Prisma ORM, Tailwind CSS, NextAuth.js, Vercel

Prerequisites

  • Claude Code installed and configured
  • Node.js 18+ installed
  • A Supabase account (free tier)
  • A Vercel account (free tier)
  • A GitHub account

The Build Session

Total time: 3 hours 22 minutes. 7 prompts.

Prompt 1: Project Scaffolding and Core Blog (Minute 0)

This was my biggest first prompt so far. For a full-stack app, I wanted to give Claude as much context as possible upfront:

Create a full-stack blog with Next.js 15 App Router. Features:

1. Public pages:
   - / (home) — list of published posts with pagination (10 per page)
   - /posts/[slug] — individual post with markdown rendered to HTML
   - /categories/[slug] — posts filtered by category

2. Admin pages (behind auth):
   - /admin — dashboard with post count, recent drafts
   - /admin/posts — list all posts (published + drafts)
   - /admin/posts/new — create new post
   - /admin/posts/[id]/edit — edit existing post

3. Database (Prisma + PostgreSQL):
   - Post: id, title, slug, content (markdown), excerpt, published,
     publishedAt, createdAt, updatedAt, categoryId, seoTitle, seoDescription
   - Category: id, name, slug
   - Tag: id, name, slug
   - PostTag: postId, tagId (many-to-many)

4. Auth: NextAuth.js with credentials provider (email + password)
   - Single admin user (seeded via Prisma)

5. Styling: Tailwind CSS with a clean, minimal design
   - Light mode only for now
   - Responsive on mobile

Use @uiw/react-md-editor for the markdown editor in admin.
Use react-markdown + rehype-highlight for rendering posts on the public side.

What Claude generated:

A complete project in about 45 seconds:

blog/
  prisma/
    schema.prisma
    seed.ts
  src/
    app/
      layout.tsx
      page.tsx
      posts/
        [slug]/
          page.tsx
      categories/
        [slug]/
          page.tsx
      admin/
        layout.tsx
        page.tsx
        posts/
          page.tsx
          new/
            page.tsx
          [id]/
            edit/
              page.tsx
      api/
        auth/
          [...nextauth]/
            route.ts
        posts/
          route.ts
          [id]/
            route.ts
    lib/
      prisma.ts
      auth.ts
    components/
      PostCard.tsx
      Pagination.tsx
      MarkdownEditor.tsx
      MarkdownRenderer.tsx
  tailwind.config.ts
  package.json
  tsconfig.json
  .env.example

The Prisma schema:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id             String   @id @default(cuid())
  email          String   @unique
  hashedPassword String
  name           String?
  createdAt      DateTime @default(now())
}

model Post {
  id             String    @id @default(cuid())
  title          String
  slug           String    @unique
  content        String
  excerpt        String?
  published      Boolean   @default(false)
  publishedAt    DateTime?
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  seoTitle       String?
  seoDescription String?
  category       Category? @relation(fields: [categoryId], references: [id])
  categoryId     String?
  tags           PostTag[]
}

model Category {
  id    String @id @default(cuid())
  name  String @unique
  slug  String @unique
  posts Post[]
}

model Tag {
  id    String    @id @default(cuid())
  name  String    @unique
  slug  String    @unique
  posts PostTag[]
}

model PostTag {
  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId String
  tag    Tag    @relation(fields: [tagId], references: [id], onDelete: Cascade)
  tagId  String

  @@id([postId, tagId])
}

The home page with pagination:

import { prisma } from "@/lib/prisma";
import PostCard from "@/components/PostCard";
import Pagination from "@/components/Pagination";

interface Props {
  searchParams: Promise<{ page?: string }>;
}

export default async function HomePage({ searchParams }: Props) {
  const params = await searchParams;
  const page = parseInt(params.page || "1");
  const perPage = 10;

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      where: { published: true },
      include: { category: true, tags: { include: { tag: true } } },
      orderBy: { publishedAt: "desc" },
      skip: (page - 1) * perPage,
      take: perPage,
    }),
    prisma.post.count({ where: { published: true } }),
  ]);

  const totalPages = Math.ceil(total / perPage);

  return (
    <main className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      <div className="space-y-8">
        {posts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
      {totalPages > 1 && (
        <Pagination currentPage={page} totalPages={totalPages} />
      )}
    </main>
  );
}

My review: The structure was impressive for a single prompt. Clean separation of pages, components, and API routes. The Prisma schema had proper relations and the right indexes.

One issue jumped out immediately: the auth setup used the credentials provider, but Claude had hardcoded a test password in the seed file without hashing it.

I installed dependencies and set up Supabase:

npm install
# Created Supabase project, copied DATABASE_URL
npx prisma migrate dev --name init
npx prisma db seed

The migration worked. The seed created the admin user and sample categories.

Time spent: 18 minutes (including Supabase setup and first migration)

Prompt 2: Fix Auth and Add Password Hashing (Minute 18)

The admin pages returned 401 errors. The auth configuration had issues:

The auth setup has problems:
1. The seed file stores the password as plain text. Use bcrypt to hash it.
2. The NextAuth credentials provider does not compare passwords correctly.
3. The admin layout does not check the session — it renders even when not
   logged in.
4. Add a /login page with email and password form. Redirect to /admin after
   login. Redirect to /login if accessing /admin without auth.

What Claude generated:

Claude fixed the auth flow. The seed file now used bcrypt:

import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";

const prisma = new PrismaClient();

async function main() {
  const hashedPassword = await bcrypt.hash("admin123", 12);

  await prisma.user.upsert({
    where: { email: "admin@blog.com" },
    update: {},
    create: {
      email: "admin@blog.com",
      hashedPassword: hashedPassword,
      name: "Admin",
    },
  });

  // Seed categories
  const categories = ["Technology", "Design", "Tutorial", "Opinion"];
  for (const name of categories) {
    await prisma.category.upsert({
      where: { slug: name.toLowerCase() },
      update: {},
      create: { name, slug: name.toLowerCase() },
    });
  }
}

main();

The NextAuth configuration:

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";

export const authOptions = {
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;

        const user = await prisma.user.findUnique({
          where: { email: credentials.email },
        });

        if (!user) return null;

        const valid = await bcrypt.compare(
          credentials.password,
          user.hashedPassword
        );

        if (!valid) return null;

        return { id: user.id, email: user.email, name: user.name };
      },
    }),
  ],
  pages: {
    signIn: "/login",
  },
  session: {
    strategy: "jwt" as const,
  },
  secret: process.env.NEXTAUTH_SECRET,
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

The admin layout now checked the session:

import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";

export default async function AdminLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect("/login");
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <nav className="bg-white border-b px-6 py-4 flex justify-between items-center">
        <div className="flex gap-6">
          <a href="/admin" className="font-semibold text-gray-900">Dashboard</a>
          <a href="/admin/posts" className="text-gray-600 hover:text-gray-900">Posts</a>
          <a href="/" className="text-gray-600 hover:text-gray-900">View Blog</a>
        </div>
        <span className="text-sm text-gray-500">{session.user?.email}</span>
      </nav>
      <main className="p-6">{children}</main>
    </div>
  );
}

I re-seeded the database, set NEXTAUTH_SECRET in .env, and tried logging in. It worked. The admin layout protected all admin routes.

Problem: The login page had no “wrong password” feedback. It just stayed on the login page if credentials were wrong. I made a mental note but moved on.

Time spent: 15 minutes

Prompt 3: Markdown Editor in Admin (Minute 33)

Add a markdown editor to the admin post creation and editing pages:
1. Use @uiw/react-md-editor for the editor component
2. Split the form: left side has title, slug, category, tags, SEO fields.
   Right side has the full-width markdown editor.
3. Auto-generate slug from title (lowercase, hyphens, remove special chars)
4. The slug should be editable after auto-generation
5. Add a "Preview" tab that renders the markdown to HTML
6. Add a "Publish" toggle and a "Save as Draft" button

What Claude generated:

A rich editor component:

"use client";

import { useState, useEffect } from "react";
import dynamic from "next/dynamic";

const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });

interface PostEditorProps {
  initialData?: {
    title: string;
    slug: string;
    content: string;
    excerpt: string;
    categoryId: string;
    seoTitle: string;
    seoDescription: string;
    published: boolean;
  };
  categories: { id: string; name: string }[];
  onSave: (data: any) => Promise<void>;
}

export default function PostEditor({ initialData, categories, onSave }: PostEditorProps) {
  const [title, setTitle] = useState(initialData?.title || "");
  const [slug, setSlug] = useState(initialData?.slug || "");
  const [content, setContent] = useState(initialData?.content || "");
  const [excerpt, setExcerpt] = useState(initialData?.excerpt || "");
  const [categoryId, setCategoryId] = useState(initialData?.categoryId || "");
  const [seoTitle, setSeoTitle] = useState(initialData?.seoTitle || "");
  const [seoDescription, setSeoDescription] = useState(initialData?.seoDescription || "");
  const [published, setPublished] = useState(initialData?.published || false);
  const [saving, setSaving] = useState(false);
  const [slugEdited, setSlugEdited] = useState(false);

  useEffect(() => {
    if (!slugEdited) {
      setSlug(
        title
          .toLowerCase()
          .replace(/[^a-z0-9\s-]/g, "")
          .replace(/\s+/g, "-")
          .replace(/-+/g, "-")
          .trim()
      );
    }
  }, [title, slugEdited]);

  const handleSave = async (shouldPublish: boolean) => {
    setSaving(true);
    try {
      await onSave({
        title,
        slug,
        content,
        excerpt,
        categoryId: categoryId || null,
        seoTitle: seoTitle || title,
        seoDescription: seoDescription || excerpt,
        published: shouldPublish,
        publishedAt: shouldPublish ? new Date().toISOString() : null,
      });
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className="space-y-6">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        <div className="lg:col-span-2 space-y-4">
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Post title"
            className="w-full text-3xl font-bold border-0 border-b-2 border-gray-200 focus:border-blue-500 focus:ring-0 p-2"
          />
          <div data-color-mode="light">
            <MDEditor
              value={content}
              onChange={(val) => setContent(val || "")}
              height={500}
              preview="live"
            />
          </div>
        </div>

        <div className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
            <input
              type="text"
              value={slug}
              onChange={(e) => {
                setSlug(e.target.value);
                setSlugEdited(true);
              }}
              className="w-full border rounded-md px-3 py-2 text-sm"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
            <select
              value={categoryId}
              onChange={(e) => setCategoryId(e.target.value)}
              className="w-full border rounded-md px-3 py-2 text-sm"
            >
              <option value="">No category</option>
              {categories.map((cat) => (
                <option key={cat.id} value={cat.id}>{cat.name}</option>
              ))}
            </select>
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Excerpt</label>
            <textarea
              value={excerpt}
              onChange={(e) => setExcerpt(e.target.value)}
              rows={3}
              className="w-full border rounded-md px-3 py-2 text-sm"
              placeholder="Short description for post cards"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">SEO Title</label>
            <input
              type="text"
              value={seoTitle}
              onChange={(e) => setSeoTitle(e.target.value)}
              className="w-full border rounded-md px-3 py-2 text-sm"
              placeholder="Defaults to post title"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">SEO Description</label>
            <textarea
              value={seoDescription}
              onChange={(e) => setSeoDescription(e.target.value)}
              rows={2}
              className="w-full border rounded-md px-3 py-2 text-sm"
              placeholder="Under 160 characters"
            />
            <p className="text-xs text-gray-400 mt-1">{seoDescription.length}/160</p>
          </div>

          <div className="flex gap-3 pt-4 border-t">
            <button
              onClick={() => handleSave(true)}
              disabled={saving || !title}
              className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
            >
              {saving ? "Saving..." : "Publish"}
            </button>
            <button
              onClick={() => handleSave(false)}
              disabled={saving || !title}
              className="flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-300 disabled:opacity-50"
            >
              Save Draft
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

My review: The editor looked good. The split layout with metadata on the right and editor on the left was a common pattern. The auto-slug generation was smart — it converted “My First Blog Post” to “my-first-blog-post” and let you edit it after.

One problem: @uiw/react-md-editor was loaded with dynamic import to avoid SSR issues. Good move. But Claude forgot to install the package.

npm install @uiw/react-md-editor

After installing, the editor loaded and worked. The live preview showed rendered markdown side by side.

Time spent: 12 minutes

Prompt 4: Full-Text Search (Minute 45)

Add full-text search to the public blog:
1. Add a search bar at the top of the home page
2. GET /api/search?q=keyword — searches post title and content
3. Use PostgreSQL full-text search with ts_vector and ts_query via Prisma raw query
4. Show results on a /search?q=keyword page with the same PostCard format
5. Add debounced search-as-you-type that shows suggestions

What Claude generated:

A search API using Prisma raw queries for PostgreSQL full-text search:

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const query = searchParams.get("q");

  if (!query || query.length < 2) {
    return NextResponse.json({ posts: [] });
  }

  const sanitized = query.replace(/[^\w\s]/g, "").trim();

  const posts = await prisma.$queryRaw`
    SELECT
      p.id, p.title, p.slug, p.excerpt, p."publishedAt",
      c.name as "categoryName", c.slug as "categorySlug",
      ts_rank(
        to_tsvector('english', p.title || ' ' || p.content),
        plainto_tsquery('english', ${sanitized})
      ) as rank
    FROM "Post" p
    LEFT JOIN "Category" c ON p."categoryId" = c.id
    WHERE p.published = true
      AND to_tsvector('english', p.title || ' ' || p.content)
          @@ plainto_tsquery('english', ${sanitized})
    ORDER BY rank DESC
    LIMIT 20
  `;

  return NextResponse.json({ posts });
}

A search page with debounced input:

"use client";

import { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";

export default function SearchPage() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const [query, setQuery] = useState(searchParams.get("q") || "");
  const [results, setResults] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const timer = setTimeout(async () => {
      if (query.length < 2) {
        setResults([]);
        return;
      }

      setLoading(true);
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
        const data = await res.json();
        setResults(data.posts);
      } finally {
        setLoading(false);
      }
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <main className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-6">Search</h1>
      <input
        type="text"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          router.replace(`/search?q=${encodeURIComponent(e.target.value)}`);
        }}
        placeholder="Search posts..."
        className="w-full border-2 border-gray-200 rounded-lg px-4 py-3 text-lg focus:border-blue-500 focus:ring-0 mb-8"
        autoFocus
      />

      {loading && <p className="text-gray-500">Searching...</p>}

      {!loading && results.length === 0 && query.length >= 2 && (
        <p className="text-gray-500">No posts found for "{query}"</p>
      )}

      <div className="space-y-6">
        {results.map((post: any) => (
          <a
            key={post.id}
            href={`/posts/${post.slug}`}
            className="block p-4 border rounded-lg hover:border-blue-300 transition-colors"
          >
            <h2 className="text-xl font-semibold mb-1">{post.title}</h2>
            {post.excerpt && (
              <p className="text-gray-600 text-sm">{post.excerpt}</p>
            )}
            <div className="flex gap-3 mt-2 text-xs text-gray-400">
              {post.categoryName && <span>{post.categoryName}</span>}
              {post.publishedAt && (
                <span>{new Date(post.publishedAt).toLocaleDateString()}</span>
              )}
            </div>
          </a>
        ))}
      </div>
    </main>
  );
}

Problem: The raw SQL query worked on Supabase but the column quoting was wrong for Prisma. The p.content field needed to be p."content" because Prisma generates quoted column names. I asked Claude to fix the quoting.

After the fix, search worked well. The debounce prevented excessive API calls. Results appeared as you typed.

Time spent: 14 minutes

Prompt 5: RSS Feed and Sitemap (Minute 59)

Add:
1. RSS feed at /feed.xml — include all published posts with title, description,
   link, and pubDate. Use proper RSS 2.0 XML format.
2. Sitemap at /sitemap.xml — include all public pages: home, all published
   posts, all categories.
3. Add meta tags in the root layout: site title, description, Open Graph tags.
4. Add per-post meta tags using generateMetadata in the post page.

What Claude generated:

The RSS feed as a route handler:

import { prisma } from "@/lib/prisma";

export async function GET() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { publishedAt: "desc" },
    take: 50,
  });

  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://example.com";

  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Blog</title>
    <link>${siteUrl}</link>
    <description>A blog built with Claude Code</description>
    <language>en</language>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
    <atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
    ${posts
      .map(
        (post) => `
    <item>
      <title><![CDATA[${post.title}]]></title>
      <link>${siteUrl}/posts/${post.slug}</link>
      <guid isPermaLink="true">${siteUrl}/posts/${post.slug}</guid>
      <description><![CDATA[${post.excerpt || ""}]]></description>
      <pubDate>${post.publishedAt ? new Date(post.publishedAt).toUTCString() : ""}</pubDate>
    </item>`
      )
      .join("")}
  </channel>
</rss>`;

  return new Response(rss, {
    headers: {
      "Content-Type": "application/xml",
      "Cache-Control": "s-maxage=3600, stale-while-revalidate",
    },
  });
}

The per-post SEO metadata:

import { Metadata } from "next";
import { prisma } from "@/lib/prisma";

interface Props {
  params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await prisma.post.findUnique({ where: { slug } });

  if (!post) return { title: "Not Found" };

  return {
    title: post.seoTitle || post.title,
    description: post.seoDescription || post.excerpt || "",
    openGraph: {
      title: post.seoTitle || post.title,
      description: post.seoDescription || post.excerpt || "",
      type: "article",
      publishedTime: post.publishedAt?.toISOString(),
    },
  };
}

My review: The RSS feed was valid XML. I checked it with an RSS validator and it passed. The sitemap generation was similar — a route handler that queried all published posts and categories, then built XML.

The generateMetadata function was correct for Next.js 15’s async params pattern.

Time spent: 10 minutes

Prompt 6: Image Upload (Minute 69)

Add image upload to the admin markdown editor:
1. Add a toolbar button in the editor that opens a file picker
2. Upload the image to Supabase Storage bucket named "blog-images"
3. Insert the image URL as markdown into the editor at the cursor position
4. Show upload progress
5. Accept only jpg, png, webp. Max 5MB.

What Claude generated:

An image upload handler:

import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { createClient } from "@supabase/supabase-js";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(request: Request) {
  const session = await getServerSession(authOptions);
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const formData = await request.formData();
  const file = formData.get("file") as File;

  if (!file) {
    return NextResponse.json({ error: "No file provided" }, { status: 400 });
  }

  const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
  if (!allowedTypes.includes(file.type)) {
    return NextResponse.json(
      { error: "Only JPG, PNG, and WebP files are allowed" },
      { status: 400 }
    );
  }

  if (file.size > 5 * 1024 * 1024) {
    return NextResponse.json(
      { error: "File size must be under 5MB" },
      { status: 400 }
    );
  }

  const ext = file.name.split(".").pop();
  const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;

  const buffer = Buffer.from(await file.arrayBuffer());

  const { error } = await supabase.storage
    .from("blog-images")
    .upload(fileName, buffer, {
      contentType: file.type,
    });

  if (error) {
    return NextResponse.json({ error: "Upload failed" }, { status: 500 });
  }

  const { data: urlData } = supabase.storage
    .from("blog-images")
    .getPublicUrl(fileName);

  return NextResponse.json({ url: urlData.publicUrl });
}

Problem: Claude used SUPABASE_SERVICE_ROLE_KEY for the upload. This works, but it means the server has full access to storage. I would normally set up Row Level Security (RLS) policies on the bucket. For this project, the service role key was acceptable since only authenticated admin users can reach the upload endpoint.

Another issue: Claude did not create the Supabase storage bucket. I had to create it manually in the Supabase dashboard and set it to public.

Time spent: 18 minutes (including Supabase Storage setup)

Prompt 7: Core Web Vitals Optimization (Minute 87)

Optimize the blog for Core Web Vitals:
1. Add next/image for all images in blog posts (create a custom markdown
   renderer that replaces img tags with next/image)
2. Add ISR (Incremental Static Regeneration) to the home page and post
   pages  revalidate every 60 seconds
3. Add proper loading states with Suspense boundaries
4. Minimize layout shift  set explicit dimensions on images and embeds
5. Add a robots.txt at /robots.txt

What Claude generated:

A custom markdown renderer that handled images:

import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import Image from "next/image";

interface Props {
  content: string;
}

export default function MarkdownRenderer({ content }: Props) {
  return (
    <article className="prose prose-lg max-w-none">
      <ReactMarkdown
        rehypePlugins={[rehypeHighlight]}
        components={{
          img: ({ src, alt }) => {
            if (!src) return null;
            return (
              <span className="block relative w-full aspect-video my-6">
                <Image
                  src={src}
                  alt={alt || ""}
                  fill
                  className="object-contain rounded-lg"
                  sizes="(max-width: 768px) 100vw, 768px"
                />
              </span>
            );
          },
          pre: ({ children }) => (
            <pre className="overflow-x-auto rounded-lg">{children}</pre>
          ),
          code: ({ className, children, ...props }) => (
            <code className={className} {...props}>
              {children}
            </code>
          ),
        }}
      />
    </article>
  );
}

ISR was added with the revalidate export:

export const revalidate = 60;

Claude also added a loading skeleton:

export default function Loading() {
  return (
    <main className="max-w-3xl mx-auto px-4 py-12">
      <div className="animate-pulse space-y-8">
        <div className="h-10 w-48 bg-gray-200 rounded" />
        {[1, 2, 3].map((i) => (
          <div key={i} className="space-y-3">
            <div className="h-6 w-3/4 bg-gray-200 rounded" />
            <div className="h-4 w-full bg-gray-100 rounded" />
            <div className="h-4 w-2/3 bg-gray-100 rounded" />
          </div>
        ))}
      </div>
    </main>
  );
}

Time spent: 15 minutes

The remaining time (about 1 hour 40 minutes) was spent on:

  • Deploying to Vercel (20 minutes — environment variables, build errors)
  • Fixing Prisma Client generation in the Vercel build (prisma generate needed in the build script)
  • Testing all flows end-to-end: create post, edit, publish, search, view
  • Fixing a bug where the markdown editor did not preserve content when switching between “write” and “preview” tabs
  • Fixing the slug uniqueness constraint — creating two posts with the same title crashed instead of showing a friendly error
  • Adding a “Back to posts” link in the admin editor (Claude forgot navigation)

Total time: 3 hours 22 minutes.

What Went Right

  • Project structure was professional. The folder organization, Prisma schema, and API routes followed Next.js 15 best practices. No deprecated patterns.
  • The markdown editor worked surprisingly well. @uiw/react-md-editor with dynamic import was a solid choice. Live preview was smooth.
  • Pagination and search were correct. The pagination used proper SQL offset/limit, and full-text search used PostgreSQL’s native ts_vector instead of LIKE queries.
  • SEO metadata was comprehensive. Per-page generateMetadata, RSS feed, sitemap, and robots.txt covered all the basics.
  • ISR was properly configured. The blog would stay fast even with heavy traffic, revalidating content every 60 seconds.

What Went Wrong

1. Authentication Took 3 Prompts to Get Right

The initial auth setup was broken. Claude generated the credentials provider but did not hash passwords, did not create a login page, and did not protect admin routes. Each fix required a separate prompt.

Lesson: For full-stack apps, make auth requirements extremely explicit in the first prompt. Say: “Use bcrypt for passwords. Create a /login page. Protect all /admin routes with session checks.”

2. Supabase Storage Was Not Configured

Claude generated code that uploaded to Supabase Storage but did not mention that you need to create the bucket manually. The upload endpoint returned 500 errors with no useful message until I checked the Supabase dashboard.

Lesson: AI cannot set up external services. Document every external setup step (create bucket, set public, add CORS) in your notes.

3. Prisma Build Errors on Vercel

The Vercel deployment failed because prisma generate was not in the build script. Prisma Client needs to be generated during the build, not just during development.

Lesson: Always add "build": "prisma generate && next build" to your package.json. This is a known Prisma + Vercel issue.

4. No Error Handling on Post Creation

Creating a post with a duplicate slug crashed the app with a raw Prisma error. There was no try/catch on the API route, no user-friendly error message.

Lesson: Add “include proper error handling with user-friendly messages” to your prompts. AI code tends to assume the happy path.

5. Navigation Was Incomplete

The admin panel had no “back” button on the editor page. The public blog had no link to categories. Claude built the pages but forgot the navigation between them.

Lesson: AI focuses on individual pages, not user flows. Review the navigation yourself or add “include breadcrumbs and back links” to your prompt.

The Final Result

A deployed blog platform with:

  • Public blog with paginated posts, search, and categories
  • Admin panel with markdown editor and image uploads
  • Authentication with hashed passwords
  • RSS feed and sitemap for SEO
  • ISR for fast page loads
  • Deployed on Vercel + Supabase (free tier)

To run it yourself:

# Clone and install
git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout fullstack-blog
npm install

# Set up environment
cp .env.example .env
# Add your DATABASE_URL, NEXTAUTH_SECRET, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY

# Set up database
npx prisma migrate dev --name init
npx prisma db seed

# Run locally
npm run dev

# Default admin: admin@blog.com / admin123

Time and Cost

  • Total time: 3 hours 22 minutes
  • Claude prompts: 7 (plus 6 fixes)
  • Estimated token usage: ~80,000 tokens
  • Hosting cost: $0/month (Vercel free tier + Supabase free tier)
  • Dependencies: Next.js, Prisma, NextAuth, Tailwind, react-md-editor, react-markdown

Lessons Learned

1. Full-stack apps need detailed first prompts. My 200-word first prompt saved at least an hour of back-and-forth. Include database schema, page structure, auth requirements, and specific libraries.

2. Authentication is always the hardest part. Whether you build it manually or with AI, auth takes the most debugging time. Budget 30% of your total time for auth alone.

3. External services need manual setup. Claude can write code that talks to Supabase, Vercel, and any API. But it cannot create accounts, set up buckets, or configure environment variables. Keep a checklist of manual steps.

4. Test the deploy early. I waited until minute 87 to deploy. If I had deployed after the first prompt, I would have caught the Prisma build issue 2 hours earlier.

5. Full-stack projects are where vibe coding truly shines. Building this blog from scratch manually would take 15-20 hours. With Claude, it took 3.5 hours. The savings grow with project complexity because there is more boilerplate to generate.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: fullstack-blog)

What’s Next?

Next, we build a Weather Dashboard with Claude — React + API Integration. External APIs, charts, geolocation, and making it a PWA. Same format: real prompts, real mistakes, real results.