跳到正文
DEV Community· PeekLaunch·· 3 小時前AI 評分56

使用 Next.js 和 Prisma 建構產品發佈平台的經驗教訓

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

AI 導讀

PeekLaunch 作者分享使用 Next.js App Router、Prisma 和 Postgres 建構產品發佈平台的經驗,核心做法是將高快取的內容頁與即時互動層分開。文章建議用精細的快取標籤控制重新驗證,並以未快取端點和樂觀更新處理投票、評論及留言。資料庫結構變更應在部署時透過 Prisma Migrate 執行,而非放進請求處理流程;產品頁和歷史週榜亦應保持可被搜尋引擎發現。

正文

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.

1. Cache the board, but tag it precisely

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.

2. 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) invalidates every cached entry carrying that tag, wherever it's used.
  • revalidatePath(path, type?) invalidates the cache for a route. revalidatePath("/", "layout") effectively invalidates everything under the root layout.

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:

  1. Call revalidation from Server Actions or Route Handlers, not during rendering. Next.js doesn't allow revalidating while a page is being rendered, and it will throw.
  2. Prefer the narrowest tag that covers the change:
"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).

3. Live upvotes: don't route every vote through the page cache

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:

  • The cached page renders the product and a vote count that may be a few minutes old. That's fine for crawlers and first paint.
  • A small Route Handler returns live, uncached numbers.
  • A client component shows an optimistic update the instant the user clicks, then reconciles with the server.
// 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 });
}
"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.

4. Never change your schema at request time

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:

  • Schema changes belong in migrations, run once at deploy time with Prisma Migrate (prisma migrate deploy in your build or release step), not inside request handlers.
  • Don't $disconnect() per request in a long-lived or serverless Next.js process. Use a single shared client:
// 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;
  • Put timeouts on anything that can block, and use a connection pooler if you're on serverless Postgres.
  • Be suspicious of fixes that change cache behavior. Invalidating more aggressively doesn't just make data fresher. It moves load onto code paths that may never have been exercised under traffic.

5. Treat SEO as a feature

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

  • Every product gets a permanent, statically rendered page with generateMetadata for titles, descriptions, and Open Graph images.
  • Archives of past weeks keep older launches reachable through internal links instead of dropping them off the site.
  • A sitemap.ts built from the database means new products are listed for crawlers as soon as they go live.
  • Outbound links to makers' sites are plain, crawlable <a> tags. JavaScript-only redirects are less reliable for crawlers.

Wrapping up

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.

來源:DEV Community · dev.to