cd /news/developer-tools/lessons-from-building-a-product-laun… · home › topics › developer-tools › article
[ARTICLE · art-154772] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Lessons from Building a Product Launch Platform with Next.js and Prisma

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.

by read5 min views2 publishedOct 21, 2026

I've been working on PeekLaunch, 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.

Here are the lessons that cost me the most debugging time, using the Next.js App Router with Prisma on Postgres.

The 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.

The 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:

// lib/queries.ts
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";

export const getWeeklyBoard = (week: string) =>
  unstable_cache(
    async () =>
      prisma.product.findMany({
        where: { launchWeek: week, status: "LIVE" },
        orderBy: { upvotes: "desc" },
        select: { id: true, slug: true, name: true, tagline: true, upvotes: true },
      }),
    ["board", week],
    { tags: ["board", `board:${week}`], revalidate: 300 }
  )();

export const getProduct = (slug: string) =>
  unstable_cache(
    async () => prisma.product.findUnique({ where: { slug } }),
    ["product", slug],
    { tags: [`product:${slug}`], revalidate: 3600 }
  )();

(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.)

Tag granularity is a design decision. Think about which events should refresh which pages, and name the tags after those relationships.

revalidateTag vs revalidatePath: pick the scalpel Both functions live in next/cache, and both mark cached data as stale. They differ in what they target:

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.

Two rules I follow now:

"use server";
import { revalidateTag } from "next/cache";

export async function approveProduct(slug: string, week: string) {
  await prisma.product.update({ where: { slug }, data: { status: "LIVE" } });
  revalidateTag(`product:${slug}`);
  revalidateTag(`board:${week}`);
}

Check 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).

Votes 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.

What worked better was splitting the problem:

// app/api/products/[slug]/votes/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export const dynamic = "force-dynamic";

export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const product = await prisma.product.findUnique({
    where: { slug },
    select: { upvotes: true },
  });
  return NextResponse.json({ upvotes: product?.upvotes ?? 0 });
}
js
"use client";
import { useOptimistic, useTransition } from "react";
import { upvote } from "./actions";

export function UpvoteButton({ slug, initial }: { slug: string; initial: number }) {
  const [count, addOptimistic] = useOptimistic(initial, (c, d: number) => c + d);
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          addOptimistic(1);
          await upvote(slug); // writes to DB, no layout-wide revalidation
        })
      }
    >
      ▲ {count}
    </button>
  );
}

The 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.

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.

This 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.

Lessons:

prisma migrate deploy in your build or release step), not inside request handlers.$disconnect() per request

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

A launch platform only helps its makers if product pages are discoverable. A few things that matter:

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. The core lesson from building PeekLaunch 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.

If you're building something similar, I'd love to hear how you handle live counters on cached pages. Drop it in the comments.

── more in #developer-tools 4 stories · sorted by recency
openalternative.co · · #developer-tools
Elmo
── more on @peeklaunch 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/lessons-from-buildin…] indexed:0 read:5min 2026-10-21 · —