{"slug": "lessons-from-building-a-product-launch-platform-with-next-js-and-prisma", "title": "Lessons from Building a Product Launch Platform with Next.js and Prisma", "summary": "A developer built PeekLaunch, a weekly SaaS and AI tool launch board, using Next.js App Router with Prisma on Postgres, and documented the caching architecture that made it work. The approach uses tagged caching with unstable_cache for weekly boards and product pages, targeted revalidateTag calls instead of layout-wide revalidatePath invalidation, and optimistic client-side updates with a force-dynamic votes endpoint to keep high-frequency upvotes from collapsing the cache. The developer warns that a single revalidatePath(\"/\", \"layout\") call in production can re-render every page and hit the database on each comment.", "body_md": "I've been working on [PeekLaunch](https://peeklaunch.com), a weekly launch board where makers submit a SaaS or AI tool, it goes live on that week's board, and other builders upvote, rate, and review it. On paper it's a simple CRUD app. In practice, a launch board is a caching puzzle. The pages are read constantly and need to be fast and crawlable, but votes and reviews change all the time and users expect to see their own action reflected immediately.\n\nHere are the lessons that cost me the most debugging time, using the Next.js App Router with Prisma on Postgres.\n\nThe weekly board, product pages, and archives are perfect for static rendering with revalidation. They're read far more often than they change, and search engines love fast HTML.\n\nThe mistake I made early was caching big chunks of data with no way to invalidate them selectively. The fix is to give cached data **tags** that match your invalidation needs:\n\n``` js\n// lib/queries.ts\nimport { unstable_cache } from \"next/cache\";\nimport { prisma } from \"@/lib/prisma\";\n\nexport const getWeeklyBoard = (week: string) =>\n  unstable_cache(\n    async () =>\n      prisma.product.findMany({\n        where: { launchWeek: week, status: \"LIVE\" },\n        orderBy: { upvotes: \"desc\" },\n        select: { id: true, slug: true, name: true, tagline: true, upvotes: true },\n      }),\n    [\"board\", week],\n    { tags: [\"board\", `board:${week}`], revalidate: 300 }\n  )();\n\nexport const getProduct = (slug: string) =>\n  unstable_cache(\n    async () => prisma.product.findUnique({ where: { slug } }),\n    [\"product\", slug],\n    { tags: [`product:${slug}`], revalidate: 3600 }\n  )();\n```\n\n(Newer Next.js versions also offer the `\"use cache\"` directive with `cacheTag()`. The idea is the same: tag what you cache, and invalidate by tag.)\n\nTag granularity is a design decision. Think about which events should refresh which pages, and name the tags after those relationships.\n\n`revalidateTag` vs `revalidatePath`: pick the scalpel\nBoth functions live in `next/cache`, and both mark cached data as stale. They differ in *what* they target:\n\n`revalidateTag(tag)`` revalidatePath(path, type?)``revalidatePath(\"/\", \"layout\")` effectively invalidates That layout-wide call is tempting because it \"just works\" in development. In production it means one comment causes every page on the site to re-render and every cached query to hit the database again. On a launch board with a burst of activity, that's how you take your own database down.\n\nTwo rules I follow now:\n\n``` js\n\"use server\";\nimport { revalidateTag } from \"next/cache\";\n\nexport async function approveProduct(slug: string, week: string) {\n  await prisma.product.update({ where: { slug }, data: { status: \"LIVE\" } });\n  revalidateTag(`product:${slug}`);\n  revalidateTag(`board:${week}`);\n}\n```\n\nCheck the docs for the version you're on. The signatures and recommended helpers have shifted between major releases (recent versions add a cache-life argument to `revalidateTag` and an `updateTag` helper for read-your-own-writes in Server Actions).\n\nVotes are the highest-frequency write on a launch board. If every upvote invalidates the board and the product page, your cache hit rate collapses on exactly the days that matter.\n\nWhat worked better was splitting the problem:\n\n``` js\n// app/api/products/[slug]/votes/route.ts\nimport { NextResponse } from \"next/server\";\nimport { prisma } from \"@/lib/prisma\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {\n  const { slug } = await params;\n  const product = await prisma.product.findUnique({\n    where: { slug },\n    select: { upvotes: true },\n  });\n  return NextResponse.json({ upvotes: product?.upvotes ?? 0 });\n}\njs\n\"use client\";\nimport { useOptimistic, useTransition } from \"react\";\nimport { upvote } from \"./actions\";\n\nexport function UpvoteButton({ slug, initial }: { slug: string; initial: number }) {\n  const [count, addOptimistic] = useOptimistic(initial, (c, d: number) => c + d);\n  const [pending, startTransition] = useTransition();\n\n  return (\n    <button\n      disabled={pending}\n      onClick={() =>\n        startTransition(async () => {\n          addOptimistic(1);\n          await upvote(slug); // writes to DB, no layout-wide revalidation\n        })\n      }\n    >\n      ▲ {count}\n    </button>\n  );\n}\n```\n\nThe Server Action records the vote (with a unique constraint on `userId + productId`, so double clicks don't double count) and, at most, revalidates the one narrow tag it needs.\n\n**Reuse the pattern.** The same pattern fixes reviews and comments. When a user submits a review, they expect to see it straight away. If the comment list comes from the ISR-cached page, they won't, and they'll assume it failed. Load the discussion from an uncached endpoint like `/api/products/[slug]/discussion`, append the new comment optimistically, and return a clear success state from the action. Once I treated votes, reviews, and comments as the same \"live island inside a cached page\" problem, a whole class of \"it saved but I can't see it\" bugs disappeared.\n\nThis one is embarrassing, but it's worth sharing. At one point a helper ensured some rating columns existed by running an `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` before certain queries, then calling `prisma.$disconnect()`. It worked locally. In production, as soon as a cache change routed more traffic to live queries, requests began piling up behind the DDL and the connection churn, and pages hung.\n\nLessons:\n\n`prisma migrate deploy` in your build or release step), not inside request handlers.`$disconnect()` per request\n\n``` js\n// lib/prisma.ts\nimport { PrismaClient } from \"@prisma/client\";\n\nconst globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };\n\nexport const prisma = globalForPrisma.prisma ?? new PrismaClient();\n\nif (process.env.NODE_ENV !== \"production\") globalForPrisma.prisma = prisma;\n```\n\nA launch platform only helps its makers if product pages are discoverable. A few things that matter:\n\n`generateMetadata` for titles, descriptions, and Open Graph images.`sitemap.ts` built from the database means new products are listed for crawlers as soon as they go live.`<a>` tags. JavaScript-only redirects are less reliable for crawlers.\nThe core lesson from building [PeekLaunch](https://peeklaunch.com) is that a \"simple\" content site with social features is really two systems: a heavily cached, crawlable read layer and a small, live, uncached interaction layer. Keep them separate, connect them with narrow cache tags, and keep slow or risky operations like schema changes out of the request path entirely.\n\nIf you're building something similar, I'd love to hear how you handle live counters on cached pages. Drop it in the comments.", "url": "https://wpnews.pro/news/lessons-from-building-a-product-launch-platform-with-next-js-and-prisma", "canonical_source": "https://dev.to/peeklaunch/lessons-from-building-a-product-launch-platform-with-nextjs-and-prisma-36co", "published_at": "2026-10-21 18:23:59+00:00", "updated_at": "2026-10-21 18:40:11.309629+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["PeekLaunch", "Next.js", "Prisma", "Postgres", "React"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/lessons-from-building-a-product-launch-platform-with-next-js-and-prisma", "markdown": "https://wpnews.pro/news/lessons-from-building-a-product-launch-platform-with-next-js-and-prisma.md", "text": "https://wpnews.pro/news/lessons-from-building-a-product-launch-platform-with-next-js-and-prisma.txt", "jsonld": "https://wpnews.pro/news/lessons-from-building-a-product-launch-platform-with-next-js-and-prisma.jsonld"}}