This is the capstone article. Everything from 14 projects comes together.
We started this series with a CLI todo app that took 35 minutes. Then we built REST APIs, Chrome extensions, full-stack blogs, SaaS dashboards, and mobile apps. Each project taught us something about working with Claude Code.
Now the question: can we take a real product idea — something that could make money — from concept to deployed, production-ready app in a single working day?
Not a prototype. Not a demo. A real app with authentication, a database, proper error handling, tests, monitoring, and a domain name. Something you could show to users.
Total time: 7 hours 42 minutes. The app is live. Here is exactly how it happened.
The Idea
I chose a product that solves a real problem: LinkVault — a bookmark manager for developers.
Why this idea:
- Developers save hundreds of links (docs, Stack Overflow, GitHub repos, blog posts) and can never find them again
- Browser bookmarks are messy and unsearchable
- Existing tools (Pocket, Raindrop) are not developer-focused — no syntax highlighting, no tag suggestions, no CLI access
- Simple enough to build in a day, complex enough to be useful
Features for the MVP:
- Save links with title, description, tags
- Auto-fetch page title and description from URL
- Full-text search across all saved links
- Tag-based filtering
- Collections (folders)
- Browser extension for quick saving
- API access with personal API key
- Import bookmarks from Chrome/Firefox
The Hour-by-Hour Timeline
Here is the full day, documented as it happened.
8:00 AM — Planning (30 minutes)
Before writing a single line of code, I spent 30 minutes planning. This is the most important step. Every minute spent planning saves five minutes of debugging later.
I created a PLAN.md file:
# LinkVault — MVP Plan
## Tech Stack Decision
- Next.js 15 (App Router) — fast to develop, easy to deploy on Vercel
- PostgreSQL (Supabase) — free tier, managed, full-text search built in
- Prisma — type-safe database access
- NextAuth.js — Google + GitHub auth (developers already have these accounts)
- Tailwind CSS + shadcn/ui — consistent, professional UI
- Vercel — deployment with preview URLs
## Database Schema
- User: id, name, email, image, apiKey, createdAt
- Link: id, url, title, description, userId, collectionId, createdAt
- Tag: id, name, userId
- LinkTag: linkId, tagId
- Collection: id, name, userId, createdAt
## MVP Pages
- / — landing page
- /dashboard — saved links with search and filter
- /collections — manage collections
- /settings — API key, import bookmarks, account
- /api/links — REST API for CLI and extension
## Priority Order
1. Auth + database schema (foundation)
2. Save and list links (core feature)
3. Search and tags (the differentiator)
4. Collections (organization)
5. API + API key (developer feature)
6. Chrome extension (convenience)
7. Import from browser (onboarding)
8. Polish and deploy
## Non-MVP (Future)
- AI tag suggestions
- Link health checking (detect broken links)
- Sharing collections publicly
- Team collections
- CLI tool (npm package)
This plan took 30 minutes but saved hours of decision-making later. Every prompt I gave Claude referenced this plan.
8:30 AM — Foundation: Auth + Database + Landing Page (Prompt 1)
Create a bookmark manager called "LinkVault" with Next.js 15 App Router.
Phase 1 — Foundation:
1. Landing page at / with:
- Hero: "Your developer bookmarks, organized and searchable"
- Features: full-text search, tags, collections, API access, browser extension
- CTA: "Get Started — Free" button linking to auth
- Clean design with Tailwind + shadcn/ui
2. Auth with NextAuth.js:
- GitHub provider (primary — developers already have GitHub)
- Google provider (secondary)
- Session in JWT
3. Database (Prisma + Supabase PostgreSQL):
- User: id, name, email, image, apiKey (uuid, generated on first login), createdAt
- Link: id, url, title, description, favicon, userId, collectionId (nullable),
createdAt, updatedAt
- Tag: id, name, color, userId
- LinkTag: linkId, tagId (composite primary key)
- Collection: id, name, icon, userId, createdAt
4. Middleware: protect /dashboard/* routes, redirect unauthenticated to /
5. Basic dashboard shell at /dashboard:
- Sidebar: collections list, tags list
- Main area: placeholder for link list
- Header: search bar, "Add Link" button, user avatar
What Claude generated:
A complete Next.js project with all the foundations. The landing page was professional, auth worked with both providers, and the database schema was clean.
Time to first working page load: 22 minutes. Auth working: 35 minutes total.
Issue: Claude set the Supabase connection string in .env but used DATABASE_URL without the ?pgbouncer=true&connection_limit=1 parameters that Supabase requires for serverless environments. I added the connection pooling parameters.
Time: 8:30-9:15 (45 minutes)
9:15 AM — Core Feature: Save and List Links (Prompt 2)
Add the core feature — saving and listing links:
1. "Add Link" dialog (opened from header button):
- URL input (required)
- When URL is entered, auto-fetch title, description, and favicon from the page
- Title input (pre-filled from fetch, editable)
- Description textarea (pre-filled, editable)
- Tag selector: multi-select from existing tags or create new
- Collection selector: optional dropdown
- "Save" button
2. Auto-fetch endpoint: POST /api/links/fetch-metadata
- Accept { url }
- Fetch the page HTML server-side
- Extract: title (og:title or <title>), description (og:description or meta description),
favicon (link[rel="icon"])
- Return { title, description, favicon }
- Timeout: 5 seconds, return empty strings on failure
3. Link list on dashboard:
- Card layout: favicon + title + description + tags + domain + saved date
- Grid of cards (responsive: 1 col mobile, 2 col tablet, 3 col desktop)
- Click card to open URL in new tab
- Edit button (pencil icon) on hover
- Delete button (trash icon) on hover with confirmation
- Infinite scroll pagination (20 links per page)
4. API route: POST /api/links, GET /api/links, PATCH /api/links/:id, DELETE /api/links/:id
What Claude generated:
The metadata fetcher:
export async function POST(req: NextRequest) {
const { url } = await req.json();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "LinkVault/1.0 (metadata fetcher)" },
});
clearTimeout(timeout);
const html = await response.text();
// Parse metadata
const titleMatch = html.match(/<meta\s+property="og:title"\s+content="([^"]*)"/) ||
html.match(/<title>([^<]*)<\/title>/);
const descMatch = html.match(/<meta\s+property="og:description"\s+content="([^"]*)"/) ||
html.match(/<meta\s+name="description"\s+content="([^"]*)"/) ;
const faviconMatch = html.match(/<link\s+[^>]*rel="(?:shortcut )?icon"[^>]*href="([^"]*)"/);
const title = titleMatch?.[1] || "";
const description = descMatch?.[1] || "";
let favicon = faviconMatch?.[1] || "";
// Resolve relative favicon URL
if (favicon && !favicon.startsWith("http")) {
const base = new URL(url);
favicon = new URL(favicon, base.origin).toString();
}
if (!favicon) {
favicon = `${new URL(url).origin}/favicon.ico`;
}
return NextResponse.json({ title, description, favicon });
} catch {
return NextResponse.json({ title: "", description: "", favicon: "" });
}
}
The link card:
function LinkCard({ link, onEdit, onDelete }: LinkCardProps) {
return (
<div className="group relative bg-white rounded-xl border p-4 hover:shadow-md transition-shadow">
<a href={link.url} target="_blank" rel="noopener noreferrer" className="block">
<div className="flex items-start gap-3">
<img
src={link.favicon || "/default-favicon.png"}
alt=""
className="w-6 h-6 rounded mt-0.5"
onError={(e) => { (e.target as HTMLImageElement).src = "/default-favicon.png" }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{link.title}</h3>
<p className="text-sm text-gray-500 line-clamp-2 mt-1">{link.description}</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-gray-400">{new URL(link.url).hostname}</span>
<span className="text-xs text-gray-300">|</span>
<span className="text-xs text-gray-400">
{formatDistanceToNow(new Date(link.createdAt), { addSuffix: true })}
</span>
</div>
{link.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{link.tags.map((tag) => (
<span
key={tag.id}
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: tag.color + "20", color: tag.color }}
>
{tag.name}
</span>
))}
</div>
)}
</div>
</div>
</a>
{/* Actions on hover */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={(e) => { e.preventDefault(); onEdit(link); }}
className="p-1.5 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-500"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.preventDefault(); onDelete(link.id); }}
className="p-1.5 rounded-md bg-gray-100 hover:bg-red-100 text-gray-500 hover:text-red-500"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
);
}
Issue: The metadata fetcher failed on sites with strict CORS policies (they returned HTML with a login page instead of the actual content). The fix was to add more User-Agent strings and handle redirects. Not perfect, but it works for 80% of developer-relevant sites.
Time: 9:15-10:10 (55 minutes)
10:10 AM — Search and Tags (Prompt 3)
Add full-text search and tag management:
1. Search:
- Search bar in header — search across title, description, URL, and tags
- Use PostgreSQL full-text search (tsvector + tsquery) via Prisma raw query
- Show results as you type (debounce 300ms)
- Highlight matching terms in results
2. Tag management:
- Tags in sidebar: show tag list with link count
- Click tag to filter links
- Create new tags inline (type name + press Enter)
- Tag colors: auto-assign from a preset palette
- Edit tag: rename, change color
- Delete tag: remove from all links
3. Combined filtering:
- Search + tag filter work together (AND logic)
- URL query params: ?q=search&tag=react&collection=default
- Browser back/forward works with filters
What Claude generated:
The PostgreSQL full-text search:
async function searchLinks(userId: string, query: string, tagId?: string, collectionId?: string) {
let where: any = { userId };
if (tagId) {
where.tags = { some: { tagId } };
}
if (collectionId) {
where.collectionId = collectionId;
}
if (query) {
// Use PostgreSQL full-text search
const links = await prisma.$queryRaw`
SELECT l.*,
ts_rank(
to_tsvector('english', l.title || ' ' || COALESCE(l.description, '') || ' ' || l.url),
plainto_tsquery('english', ${query})
) as rank
FROM "Link" l
WHERE l."userId" = ${userId}
AND to_tsvector('english', l.title || ' ' || COALESCE(l.description, '') || ' ' || l.url)
@@ plainto_tsquery('english', ${query})
ORDER BY rank DESC
LIMIT 50
`;
return links;
}
return prisma.link.findMany({
where,
include: { tags: { include: { tag: true } } },
orderBy: { createdAt: "desc" },
take: 50,
});
}
The tag sidebar:
function TagSidebar({ tags, activeTag, onSelectTag }: TagSidebarProps) {
const [newTagName, setNewTagName] = useState("");
const createTag = async () => {
if (!newTagName.trim()) return;
const colors = ["#ef4444", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ec4899"];
const color = colors[Math.floor(Math.random() * colors.length)];
await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newTagName.trim(), color }),
});
setNewTagName("");
// Refetch tags...
};
return (
<div className="space-y-1">
<h3 className="text-xs font-semibold text-gray-500 uppercase px-2 mb-2">Tags</h3>
{tags.map((tag) => (
<button
key={tag.id}
onClick={() => onSelectTag(tag.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm ${
activeTag === tag.id ? "bg-gray-100" : "hover:bg-gray-50"
}`}
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span className="flex-1 text-left truncate">{tag.name}</span>
<span className="text-xs text-gray-400">{tag._count.links}</span>
</button>
))}
{/* Inline new tag */}
<div className="px-2 pt-2">
<input
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createTag()}
placeholder="New tag..."
className="w-full text-sm px-2 py-1 border rounded-md"
/>
</div>
</div>
);
}
Issue: The raw SQL query for full-text search did not include the tag join, so filtered results lost their tag information. I added a second query to fetch tags for the search results. Not ideal, but full-text search with Prisma requires raw SQL.
Time: 10:10-11:05 (55 minutes)
11:05 AM — Collections (Prompt 4)
Add collections (folders for organizing links):
1. Collections page at /dashboard/collections:
- List all collections with link count
- Create collection: name + optional icon (emoji)
- Edit and delete collections
- Default collection: "Unsorted" (cannot be deleted)
2. Collection filter in sidebar:
- Show collections above tags
- Click to filter links by collection
- "All Links" option at the top
3. Move links between collections:
- In link edit dialog, add collection dropdown
- Drag link to collection in sidebar (stretch goal — skip if complex)
4. Bulk actions:
- Select multiple links with checkboxes
- Bulk move to collection
- Bulk add tag
- Bulk delete
What Claude generated:
Clean CRUD for collections, sidebar integration, and bulk actions. The bulk action toolbar appeared at the top of the link list when links were selected:
function BulkActionBar({ selectedCount, onMove, onTag, onDelete }: BulkActionBarProps) {
if (selectedCount === 0) return null;
return (
<div className="sticky top-0 z-10 bg-blue-50 border-b border-blue-200 p-3 flex items-center gap-4">
<span className="text-sm font-medium text-blue-800">
{selectedCount} selected
</span>
<button onClick={onMove} className="text-sm text-blue-600 hover:text-blue-800">
Move to collection
</button>
<button onClick={onTag} className="text-sm text-blue-600 hover:text-blue-800">
Add tag
</button>
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-800">
Delete
</button>
</div>
);
}
Issue: None. Collections were straightforward CRUD.
Time: 11:05-11:45 (40 minutes)
11:45 AM — Lunch Break (30 minutes)
Stepped away. Came back refreshed. This is important — long coding sessions have diminishing returns after 3-4 hours.
12:15 PM — API Access and API Keys (Prompt 5)
Add developer API access:
1. API key management in /dashboard/settings:
- Show current API key (masked with "reveal" button)
- "Regenerate" button with confirmation
- Copy to clipboard button
- API key is a UUID generated on first login
2. API authentication middleware:
- Accept API key via X-API-Key header
- Look up user by API key
- Apply to all /api/v1/* routes
3. Public API endpoints (authenticated via API key):
- GET /api/v1/links — list links (with search, tag, collection filters)
- POST /api/v1/links — save a new link
- PATCH /api/v1/links/:id — update a link
- DELETE /api/v1/links/:id — delete a link
- GET /api/v1/tags — list tags
- GET /api/v1/collections — list collections
4. API docs page at /docs:
- Simple documentation showing all endpoints with curl examples
- Rate limit: 100 requests/minute
5. Rate limiting:
- In-memory rate limiter (simple token bucket)
- Return 429 with Retry-After header when exceeded
What Claude generated:
The API key middleware:
export async function withApiAuth(req: NextRequest): Promise<{ user: User } | NextResponse> {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) {
return NextResponse.json(
{ error: "API key required. Pass via X-API-Key header." },
{ status: 401 }
);
}
const user = await prisma.user.findUnique({ where: { apiKey } });
if (!user) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
return { user };
}
The API docs page:
function ApiDocsPage() {
return (
<div className="max-w-3xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-2">LinkVault API</h1>
<p className="text-gray-500 mb-8">Access your bookmarks programmatically.</p>
<div className="space-y-8">
<ApiEndpoint
method="GET"
path="/api/v1/links"
description="List your saved links"
params={[
{ name: "q", type: "string", description: "Search query" },
{ name: "tag", type: "string", description: "Filter by tag name" },
{ name: "collection", type: "string", description: "Filter by collection name" },
{ name: "limit", type: "number", description: "Results per page (default: 20, max: 100)" },
{ name: "offset", type: "number", description: "Pagination offset" },
]}
example={`curl -H "X-API-Key: your-api-key" \\
https://linkvault.app/api/v1/links?q=react&limit=10`}
/>
<ApiEndpoint
method="POST"
path="/api/v1/links"
description="Save a new link"
body={{
url: "https://example.com/article",
title: "Optional title (auto-fetched if omitted)",
description: "Optional description",
tags: ["react", "tutorial"],
collection: "Frontend",
}}
example={`curl -X POST -H "X-API-Key: your-api-key" \\
-H "Content-Type: application/json" \\
-d '{"url": "https://react.dev", "tags": ["react"]}' \\
https://linkvault.app/api/v1/links`}
/>
{/* More endpoints... */}
</div>
</div>
);
}
Issue: The rate limiter used an in-memory Map, which resets on every deployment (Vercel serverless functions are stateless). For a real production app, I would use Redis or Upstash. For the MVP, the in-memory limiter is good enough because Vercel functions have some warm instance reuse.
Time: 12:15-1:10 PM (55 minutes)
1:10 PM — Chrome Extension (Prompt 6)
Create a Chrome extension for quick link saving:
1. Manifest V3 popup extension
2. Popup shows:
- Current page title and URL (pre-filled)
- Tag selector (fetches tags from API)
- Collection selector (fetches collections from API)
- "Save" button
- Success/error message
3. Settings:
- API key input (saved in chrome.storage.sync)
- API URL input (default: https://linkvault.app)
4. Keyboard shortcut: Ctrl+Shift+S to open popup and save current page
5. When the page has an existing bookmark, show "Already saved" instead of the save form
Keep it minimal — popup only, no background scripts beyond the keyboard shortcut.
What Claude generated:
A complete Chrome extension in a extension/ directory:
{
"manifest_version": 3,
"name": "LinkVault",
"version": "1.0.0",
"description": "Save bookmarks to LinkVault with one click",
"permissions": ["activeTab", "storage"],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"commands": {
"_execute_action": {
"suggested_key": { "default": "Ctrl+Shift+S" },
"description": "Save current page to LinkVault"
}
}
}
The popup with API integration:
<div id="app">
<div id="loading" class="text-center py-4">Loading...</div>
<div id="save-form" class="hidden">
<div class="mb-3">
<label class="text-xs text-gray-500">Title</label>
<input id="title" class="w-full px-2 py-1.5 border rounded text-sm" />
</div>
<div class="mb-3">
<label class="text-xs text-gray-500">Tags</label>
<div id="tag-list" class="flex flex-wrap gap-1 mb-1"></div>
</div>
<div class="mb-3">
<label class="text-xs text-gray-500">Collection</label>
<select id="collection" class="w-full px-2 py-1.5 border rounded text-sm">
<option value="">None</option>
</select>
</div>
<button id="save-btn" class="w-full py-2 bg-blue-600 text-white rounded text-sm font-medium">
Save to LinkVault
</button>
</div>
<div id="already-saved" class="hidden text-center py-4">
<div class="text-green-600 text-lg mb-1">Already saved</div>
<p class="text-sm text-gray-500">This page is in your LinkVault.</p>
</div>
<div id="setup" class="hidden">
<p class="text-sm text-gray-600 mb-3">Enter your API key to get started.</p>
<input id="api-key" type="password" placeholder="Your API key"
class="w-full px-2 py-1.5 border rounded text-sm mb-2" />
<button id="save-key" class="w-full py-2 bg-blue-600 text-white rounded text-sm">
Save Key
</button>
</div>
</div>
Issue: The extension worked, but checking “already saved” required an API call on every popup open, which was slow. I added a simple cache using chrome.storage.local that stores the last 1000 saved URLs.
Time: 1:10-1:50 PM (40 minutes)
1:50 PM — Import from Browser (Prompt 7)
Add bookmark import from Chrome and Firefox:
1. Settings page (/dashboard/settings) section: "Import Bookmarks"
2. File upload: accept Chrome bookmarks export (HTML file) and Firefox export (JSON)
3. Parse the file client-side:
- Chrome: parse the HTML file (DL/DT/A structure)
- Firefox: parse the JSON export
4. Show preview: list of bookmarks to import with checkboxes
5. "Import Selected" button
6. Import in batches of 50 (avoid timeout)
7. Show progress bar during import
8. Auto-fetch metadata for imported links (queue in background)
What Claude generated:
The Chrome bookmark parser:
function parseChromeBookmarks(html: string): ImportedBookmark[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const bookmarks: ImportedBookmark[] = [];
const links = doc.querySelectorAll("a");
links.forEach((link) => {
const url = link.getAttribute("href");
const title = link.textContent?.trim();
const addDate = link.getAttribute("add_date");
if (url && url.startsWith("http")) {
bookmarks.push({
url,
title: title || url,
createdAt: addDate ? new Date(parseInt(addDate) * 1000).toISOString() : new Date().toISOString(),
});
}
});
return bookmarks;
}
The batch import with progress:
async function importBookmarks(bookmarks: ImportedBookmark[]) {
const batchSize = 50;
let imported = 0;
for (let i = 0; i < bookmarks.length; i += batchSize) {
const batch = bookmarks.slice(i, i + batchSize);
await fetch("/api/links/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ links: batch }),
});
imported += batch.length;
setProgress(Math.round((imported / bookmarks.length) * 100));
}
}
Issue: Importing 500+ bookmarks took about 30 seconds because each batch triggered metadata fetching. I moved metadata fetching to a background job that runs after the import completes — the links are saved immediately with just the URL and title from the export file.
Time: 1:50-2:30 PM (40 minutes)
2:30 PM — Tests (Prompt 8)
Write tests:
1. API tests using Jest + supertest:
- Auth: register via API key
- Links: CRUD operations
- Search: full-text search returns correct results
- Tags: CRUD, filtering by tag
- Rate limiting: verify 429 after 100 requests
2. E2E tests using Playwright:
- Login with test credentials
- Save a link via the "Add Link" dialog
- Search for the saved link
- Filter by tag
- Delete the link
Use a test database (separate from development).
What Claude generated:
12 API tests and 5 E2E tests. All passing after one fix (the search test expected exact match ordering, but full-text search uses relevance ranking).
Time: 2:30-3:15 PM (45 minutes)
3:15 PM — Polish (Prompt 9)
Final polish:
1. Loading states: skeleton loaders for link cards and sidebar
2. Error handling: toast notifications for save/delete/import errors
3. Empty states: "No links yet — save your first bookmark" with illustration
4. Keyboard shortcuts: / to focus search, n to open "Add Link" dialog
5. Mobile responsive: collapsible sidebar, stack cards vertically
6. SEO: meta tags, Open Graph image for landing page
7. Favicon and app icons
8. 404 page
What Claude generated:
All polish items implemented. The empty state had a simple SVG illustration. Keyboard shortcuts used a global event listener. Mobile sidebar used a slide-out drawer.
Time: 3:15-4:00 PM (45 minutes)
4:00 PM — Deployment (Prompt 10)
Deploy to production:
1. Vercel deployment:
- Create vercel.json with proper configuration
- Set up environment variables
- Configure custom domain
2. Supabase production database:
- Run Prisma migrations on production database
- Set up connection pooling
3. Monitoring:
- Add Sentry error tracking (free tier)
- Add Vercel Analytics (free)
4. Security:
- Rate limiting on all API routes
- CSRF protection
- Input sanitization
- SQL injection prevention (Prisma handles this)
- XSS prevention (React handles this)
5. Performance:
- Image optimization for favicons
- API response caching (stale-while-revalidate)
- Database query optimization (proper indexes)
Create a deployment checklist as a comment in vercel.json.
What Claude generated:
Vercel configuration, Sentry integration, and a deployment checklist. I ran through the checklist manually:
# 1. Push to GitHub
git add -A
git commit -m "LinkVault MVP — complete"
git push
# 2. Connect to Vercel
vercel link
# 3. Set environment variables
vercel env add DATABASE_URL
vercel env add NEXTAUTH_SECRET
vercel env add GITHUB_CLIENT_ID
vercel env add GITHUB_CLIENT_SECRET
vercel env add GOOGLE_CLIENT_ID
vercel env add GOOGLE_CLIENT_SECRET
vercel env add SENTRY_DSN
# 4. Deploy
vercel --prod
# 5. Run migrations on production
DATABASE_URL="production-url" npx prisma migrate deploy
# 6. Set custom domain
vercel domains add linkvault.app
Issue: The first production deployment failed because Prisma generated the client for linux-musl-openssl-3.0.x locally (my machine) but Vercel needs rhel-openssl-3.0.x. The fix was adding the binary target to schema.prisma:
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x"]
}
Second deployment succeeded. The app was live.
Time: 4:00-5:00 PM (60 minutes)
5:00 PM — Final Testing and Fixes (42 minutes)
I spent the last 42 minutes testing the production app:
- Registered with GitHub
- Saved 10 links
- Tested search
- Installed the Chrome extension and saved a link from it
- Tested the API with curl
- Imported my Chrome bookmarks (347 links)
- Tested on mobile (iPhone)
- Fixed 2 minor CSS issues on mobile
- Fixed a race condition in the import progress bar
5:42 PM — Done. The app is live.
The Final Stack
| Component | Technology | Cost |
|---|---|---|
| Frontend + API | Next.js 15 on Vercel | $0 (free tier) |
| Database | Supabase PostgreSQL | $0 (free tier) |
| Auth | NextAuth.js (GitHub + Google) | $0 |
| Error tracking | Sentry | $0 (free tier) |
| Analytics | Vercel Analytics | $0 |
| Domain | linkvault.app | $12/year |
| Chrome extension | Chrome Web Store | $5 (one-time) |
| Total | $17 first year |
Full Time Breakdown
| Time | Activity | Duration |
|---|---|---|
| 8:00 | Planning | 30 min |
| 8:30 | Auth + database + landing page | 45 min |
| 9:15 | Save and list links | 55 min |
| 10:10 | Search and tags | 55 min |
| 11:05 | Collections | 40 min |
| 11:45 | Lunch break | 30 min |
| 12:15 | API access + API keys | 55 min |
| 1:10 | Chrome extension | 40 min |
| 1:50 | Import from browser | 40 min |
| 2:30 | Tests | 45 min |
| 3:15 | Polish | 45 min |
| 4:00 | Deployment | 60 min |
| 5:00 | Final testing + fixes | 42 min |
| Total | 7 hr 42 min |
Claude prompts: 10 major prompts + ~15 fix/iteration prompts.
What Went Right
- Planning saved hours. The 30-minute planning session meant every prompt was focused. No wasted prompts on tech stack decisions or feature debates.
- Auto-metadata fetching is a killer feature. Saving a URL and having it auto-populate the title, description, and favicon makes the app feel polished. Users love it.
- Full-text search worked on first try. PostgreSQL’s built-in full-text search with
tsvectorandtsquerywas powerful enough for the MVP. No need for Elasticsearch or Algolia. - The Chrome extension added real value. Being able to save a link without leaving the current page is the difference between “I will save this later” and actually saving it.
- Deployment was smooth. Vercel + Supabase is a mature stack. After fixing the Prisma binary target issue, everything just worked.
What Went Wrong
1. Prisma Binary Target
The most frustrating bug of the day. The production deployment failed with a cryptic Prisma error about missing query engine binaries. The fix was one line, but finding it took 20 minutes of Googling.
Lesson: Always add rhel-openssl-3.0.x to Prisma binary targets when deploying to Vercel. This is a known issue that should be in every Next.js deployment checklist.
2. Supabase Connection Pooling
The default Supabase connection string does not include connection pooling parameters. Serverless functions can exhaust the connection pool quickly without ?pgbouncer=true&connection_limit=1.
Lesson: Always use the “Connection Pooling” connection string from Supabase, not the direct connection string, for serverless deployments.
3. Metadata Fetching Timeout
Some websites take longer than 5 seconds to respond, and some block server-side requests entirely. The metadata fetcher needs better error handling and fallback strategies.
Lesson: External HTTP requests are unreliable. Always have a timeout, a fallback, and graceful error handling. Never block the user on an external request.
4. Import Performance
Importing 347 bookmarks took about 45 seconds. The initial implementation fetched metadata for each link during import, which was too slow. Moving metadata fetching to a background job fixed the UX but added complexity.
Lesson: Separate “save” from “enrich.” Save the data immediately, enrich it in the background.
5. No Offline Support
The app does not work offline. If you lose internet, you cannot access your bookmarks. For a bookmark manager, this is a real limitation. Adding a service worker with cached data would fix this.
Lesson: For a developer tool that stores reference material, offline access is important. Plan for it from the start.
Prompt Templates for Your Own “Idea to Production” Day
Here are the prompt templates I used. Copy and adapt them for your own project.
Template 1: Planning Prompt
I want to build [product description].
Help me plan:
1. Tech stack recommendation (optimize for speed of development + free hosting)
2. Database schema
3. Feature priority list (what to build first for MVP)
4. Pages/routes needed
5. Third-party integrations
Template 2: Foundation Prompt
Create [app name] with [framework].
Phase 1 — Foundation:
1. Landing page with [key sections]
2. Auth with [provider] — [auth methods]
3. Database schema: [list all models with fields]
4. Middleware: protect [routes]
5. Basic shell for [main page]
Template 3: Feature Prompt
Add [feature name]:
1. [Specific requirement with details]
2. [API endpoint with request/response shape]
3. [UI component with layout description]
4. [Edge cases to handle]
Follow the existing patterns in the codebase.
Template 4: Polish Prompt
Final polish:
1. Loading states: [where]
2. Error handling: [what kind]
3. Empty states: [which pages]
4. Keyboard shortcuts: [which ones]
5. Mobile responsive: [what needs fixing]
6. SEO: [meta tags, OG images]
Template 5: Deployment Prompt
Deploy to production:
1. [Hosting platform] configuration
2. Database migration on production
3. Environment variables
4. Monitoring: [error tracking, analytics]
5. Security: [rate limiting, CSRF, etc.]
6. Performance: [caching, optimization]
Lessons From 14 Projects Applied
Here are the meta-lessons from the entire series, applied to this capstone project:
1. Plan before you prompt. (Learned in Article 6: Full-Stack Blog) — Spending 30 minutes planning saved 2+ hours of wasted prompts and refactoring.
2. One feature per prompt. (Learned in Article 1: CLI Todo App) — Each prompt focused on one feature. No “build everything at once” prompts after the foundation.
3. Test on mobile early. (Learned in Article 3: Landing Page) — I tested mobile at 5:00 PM and found 2 CSS issues. Testing at each prompt would have been better.
4. Free tiers are enough for MVP. (Learned in Article 12: SaaS Dashboard) — Total cost: $17/year. No reason to not ship.
5. Auth is always complicated. (Learned in Article 6, 11, 12) — NextAuth with multiple providers needed careful environment variable management. Test auth on production early.
6. Database migrations need production testing. (Learned in Article 12: SaaS Dashboard) — The Prisma binary target issue only appeared in production. Always deploy early, not at the end.
7. Background jobs for slow operations. (Learned in Article 10: URL Shortener) — Metadata fetching and bookmark import both benefited from background processing.
8. Claude handles boilerplate well, struggles with edge cases. (Learned in every article) — The foundation prompts always worked. The fixes were always for edge cases: connection pooling, multi-tab auth, background goroutines, etc.
Could You Actually Ship This?
Yes. But “shipped” and “production-ready” are different things.
What this app has:
- Authentication
- Core features working
- Tests passing
- Error tracking
- Deployed on a real URL
What this app needs before real users:
- Rate limiting with Redis (not in-memory)
- Offline support (service worker)
- Better import performance for 1000+ bookmarks
- Email verification
- Terms of service and privacy policy
- GDPR compliance (data export/deletion)
- Backup strategy for the database
- Load testing
The MVP is a starting point. You can ship it, get feedback, and iterate. That is the whole point — getting from idea to something real in one day, not in one month.
Time and Cost
- Total time: 7 hours 42 minutes (including 30-minute lunch)
- Claude prompts: 10 major + ~15 fixes = ~25 total
- Estimated token usage: ~150,000 tokens
- Infrastructure cost: $17/year (domain + Chrome Web Store fee)
- Hosting cost: $0/month (Vercel + Supabase free tiers)
Without Claude, this project would take 40-60 hours for an experienced developer. With Claude, it took under 8 hours. That is a 5-7x speedup.
The Complete Series Summary
15 projects. 15 different tech stacks. Here is what we built and what we learned:
| # | Project | Time | Key Lesson |
|---|---|---|---|
| 1 | CLI Todo App | 35 min | Claude scaffolds perfectly from clear prompts |
| 2 | REST API | 52 min | One feature per prompt is the best strategy |
| 3 | Landing Page | 38 min | AI-generated CSS is surprisingly good |
| 4 | Chrome Extension | 67 min | Manifest V3 is a common Claude mistake area |
| 5 | Discord Bot | 71 min | Slash command registration needs manual testing |
| 6 | Full-Stack Blog | 3h 22m | Auth is always the hardest part |
| 7 | Weather Dashboard | 2h 38m | API integration is Claude’s strength |
| 8 | Markdown Editor | 3h 47m | Desktop apps have packaging complexity |
| 9 | Budget Tracker | 4h 31m | KMP Gradle config is the enemy |
| 10 | URL Shortener | 2h 14m | Go is the best language for vibe coding |
| 11 | Real-Time Chat | 4h 38m | WebSocket auth needs careful coordination |
| 12 | SaaS Dashboard | 5h 22m | Stripe webhooks are the hardest part of SaaS |
| 13 | Multi-Agent | 3h 47m | Integration costs 30% of total time |
| 14 | KMP Mobile App | 5h 11m | KMP is at the edge of vibe coding complexity |
| 15 | Idea to Production | 7h 42m | Planning saves more time than any tool |
Total time across all 15 projects: approximately 46 hours.
Without AI: estimated 200-300 hours. That is a 4-6x overall speedup.
The speedup is largest for boilerplate-heavy projects (SaaS dashboard, full-stack blog) and smallest for platform-complex projects (KMP mobile app, Electron editor).
Final Thoughts
Vibe coding with Claude is not magic. It does not replace knowing how to program. But it fundamentally changes what one developer can build in a day.
The biggest lesson from this entire series: the quality of your prompts determines the quality of your output. Clear, specific, well-structured prompts produce working code. Vague prompts produce vague code.
If you followed along and built even a few of these projects, you now have a workflow that works. Plan, prompt, review, iterate, ship.
Go build something.
Source Code
Full source code: kemalcodes/vibe-coding-projects (branch: idea-to-production)
Related Articles
- Vibe Coding a Mobile App with Claude — Previous article
- Build a CLI Todo App with Claude — First article in this series
- Vibe Coding with Claude — Complete Series — Series landing page
- Build a SaaS Dashboard with Claude — Most similar project in the series