Skip to main content
ASoc
Tutorial

Next.js Order Confirmation: The Return URL Is a Convenience, the Webhook Is the Receipt

The return URL and the webhook are two independent HTTP calls in parallel. This storefront resolves the race by rendering the receipt only from Postgres rows the webhook wrote.

The ASoc Team10 min read

A Next.js order confirmation page is not what most tutorials tell you it is. The return URL from a hosted checkout — the page a buyer lands on right after paying — is a UX convenience, not the source of truth about the order. The webhook that fires from the payment provider is. This storefront never renders a "thank you for your purchase" page keyed off the return URL at all; the buyer is redirected to /dashboard, where the receipt appears only after the order_created webhook has landed and inserted the row. Here is why, and the exact src/app/dashboard/page.tsx code that resolves the race.

The race, in one diagram

Buyer clicks "Complete purchase"
         │
         ├────────► LemonSqueezy processes card         (server-to-server)
         │                │
         │                ├── HTTP 200 to buyer's browser
         │                │        │
         │                │        └── Redirect to your `success_url`
         │                │                   │
         │                │                   └── Buyer lands here (~200–800ms)
         │                │
         │                └── Fires `order_created` webhook to your API
         │                             │
         │                             └── Your route verifies signature,
         │                                 inserts order in Postgres (~500ms–3s)

The redirect and the webhook are two independent HTTP calls in parallel. On a good day the webhook wins by a comfortable margin; on a bad day (retry, cold start, DNS hiccup) the buyer's browser is already at your success page before your database has heard about their order. A page that greets them with "Order #12345, thanks!" from URL params trusts data the browser could have forged. A page that reads the order from Postgres shows an empty state until the webhook wins the race.

There is only one honest place to render an order confirmation: after the webhook has landed, from your own database. Which is why in this codebase, the "confirmation" is a dashboard tab, not a /thank-you route.

The two-file contract

Two files split the work:

FileJobTrusts
src/app/api/webhooks/lemonsqueezy/route.tsRecord the orderHMAC-signed webhook body
src/app/dashboard/page.tsxShow the orderSession cookie + Postgres row

Neither trusts the URL the buyer landed on. The webhook trusts nothing except the signature; the dashboard trusts nothing except the session and the row.

The webhook, minus the signature-verification detail

// src/app/api/webhooks/lemonsqueezy/route.ts
export const runtime = "nodejs"; // required for `node:crypto`

export async function POST(req: Request) {
  const raw = await req.text();                     // exact bytes, for HMAC
  const signature = req.headers.get("X-Signature");

  const result = await processWebhook(raw, signature, {
    db: createSupabaseWebhookDb(createAdminClient()),
    secret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!,
    storeId: process.env.LEMONSQUEEZY_STORE_ID!,
  });

  if (result.kind === "invalid_signature") return new Response("nope", { status: 401 });

  after(async () => {
    if (result.kind === "recorded" && result.newOrder) {
      await sendPurchaseWelcome(result.email, result.tier);
    }
  });

  return new Response("ok", { status: 200 });
}

req.text() (not req.json()) preserves the exact bytes LemonSqueezy signed — re-serializing a parsed object can byte-for-byte differ from the original, and the HMAC would fail on a payload that was in fact legitimate. after() runs the welcome email after the response body has been sent, so a slow email provider can't slow the webhook acknowledgment (LS times out and retries at ~15s).

The important property: this route is the only place in the codebase that grants a purchase. No other code path — including the dashboard render — can create an entitlement row.

The dashboard, actually reading state

The buyer's success_url on LemonSqueezy is /dashboard. That page reads the current session, queries their orders from Postgres, and renders whatever it finds:

// src/app/dashboard/page.tsx
export default async function DashboardPage() {
  const supabase = await createClient();
  const { data: claims } = await supabase.auth.getClaims();  // never getSession()
  if (!claims) redirect("/login?next=/dashboard");

  const { data: orders } = await supabase
    .from("orders")
    .select("id, tier, status, total_cents, currency, created_at, raw")
    .eq("user_id", claims.claims.sub)
    .order("created_at", { ascending: false });

  if (!orders || orders.length === 0) {
    return <EmptyState />;   // "No purchases yet."
  }

  return <PurchaseList orders={orders} />;
}

The empty state is the interesting case. On a first-time buyer, orders will be empty for the ~200ms–3s window between the redirect and the webhook. The empty state is real, current, and self-correcting: a page refresh once the webhook lands shows the row.

What the empty state actually says

Most implementations that hit this race treat the empty state as an error — a spinner, or "processing your order." Both are wrong: the row doesn't exist yet, and the page has no way to know whether it will exist. A user who cancelled their card mid-checkout gets the same empty view as a user whose webhook is 300ms late.

The right copy is the same in both cases: "No purchases yet." With a hint that new orders may take a moment to appear. The dashboard's own copy:

// src/app/dashboard/page.tsx
<div className="rounded-2xl border border-stroke bg-white p-8 text-center">
  <p className="text-text-color">No purchases yet.</p>
  <p className="mt-1 text-sm text-text-color-tertiary">
    If you just completed checkout, refresh in a moment — new orders take a few
    seconds to appear.
  </p>
</div>

No spinner, no auto-refresh, no polling. A polling loop that hammers the DB every second would be worse than the honest message: the median case resolves on the first render, and the tail case doesn't get better by asking more often.

Every order_created payload from LemonSqueezy carries a per-order signed receipt URL at data.attributes.urls.receipt. The webhook records the whole payload in orders.raw so the dashboard can pull it later:

// src/app/dashboard/page.tsx
function receiptUrlFromRaw(raw: unknown): string | null {
  if (!isRecord(raw)) return null;
  const attrs = isRecord(raw.data) ? raw.data.attributes : undefined;
  const urls = isRecord(attrs) ? attrs.urls : undefined;
  const receipt = isRecord(urls) ? urls.receipt : undefined;
  return typeof receipt === "string" ? receipt : null;
}

That URL is signed by LemonSqueezy and expires — our storefront never generates one, so there's no signing key to leak. Every "download your receipt" link on the dashboard just forwards to it.

Troubleshooting

SymptomCauseFix
Buyers occasionally report seeing "No purchases yet." after payingWebhook hasn't landed yet — the race described aboveThe current honest copy is the fix; if the tail case is common, add a "refresh in 10s" one-shot rather than continuous polling
The confirmation page shows the order but you can't find the row in the databaseYou're reading the order from URL params/localStorage, not from PostgresQuery the database keyed off the session; treat the URL as untrusted
Two orders get recorded for one purchaseThe webhook is retried by LemonSqueezy after a slow/failed responseIdempotency: dedupe by event_id in the webhook handler before insert
The welcome email never sendsIt's inside the request path and the webhook times out firstMove the email into after() — the response is acknowledged, the email runs post-response
Buyers on a different account see each other's ordersRow-level security not scoping by session sub claimEvery read uses .eq("user_id", claims.claims.sub) AND the table has an RLS policy asserting the same
Trying to read req.json() and then verify HMAC fails intermittentlyRe-serializing the parsed body doesn't byte-match the originalRead req.text() first, verify HMAC on the raw string, then parse

FAQ

Do I need a separate /thank-you route at all? No. The redirect-to-dashboard pattern above is simpler and honest. If you want a distinct URL for analytics attribution, redirect from /thank-you to /dashboard after firing the pageview — the URL exists for tracking, not for content.

How do I tell the buyer their email receipt is on its way? The dashboard's empty state or purchase row already says it. Don't render "check your email" on the return URL — it's making a promise the webhook hasn't kept yet. Once the webhook lands, the row appears and (via after()) the email fires.

What about payment providers that don't send a webhook? Every reputable provider sends one — Stripe, LemonSqueezy, Paddle, Chargebee. If you're integrating something that doesn't, you have a bigger problem: you can never be sure the payment succeeded without polling. Fix the integration first.

Should I show a spinner while the webhook lands? No. A spinner is a promise of forward progress the page can't guarantee — the row might never arrive (canceled card, webhook misconfigured). Show the honest empty state and let a manual refresh confirm.

Templates in this post

ASoc Kiln is a handmade-ceramics ecommerce template, ASoc Linen a minimalist-fashion store, and ASoc Lumen a jewelry-store template — three of the 35 shop templates whose checkout flow would benefit from the webhook-first pattern above.

Browse the full sets: Next.js shop templates, Tailwind shop templates. For the full checkout half, see nextjs-lemonsqueezy-checkout; for the download route that reads entitlement rows the webhook wrote, see nextjs-gated-file-downloads.

Keep reading

Tutorial8 min read

Next.js Pagination: The Threshold This Blog Blew Past by 3x

This codebase paginates nothing — /blog still renders 106 posts on one page, 3.5x past its own stated 30-post threshold. The searchParams pattern for when it's real.

Read more
Tutorial10 min read

Product Filtering in Next.js: Why Filters Belong in the URL

Filters in useState cannot be shared, bookmarked, or server-rendered. How to read them from searchParams — and which filtered URLs to let Google crawl.

Read more
Tutorial11 min read

A Product Image Gallery in Next.js That Google Can Actually See

Virtualizing a four-slide carousel removes three product images from the HTML. Mount every slide, starve the off-screen ones, and the ARIA that a carousel actually needs.

Read more