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.
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:
| File | Job | Trusts |
|---|---|---|
src/app/api/webhooks/lemonsqueezy/route.ts | Record the order | HMAC-signed webhook body |
src/app/dashboard/page.tsx | Show the order | Session 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.
The receipt link, from a source that can produce one
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
| Symptom | Cause | Fix |
|---|---|---|
| Buyers occasionally report seeing "No purchases yet." after paying | Webhook hasn't landed yet — the race described above | The 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 database | You're reading the order from URL params/localStorage, not from Postgres | Query the database keyed off the session; treat the URL as untrusted |
| Two orders get recorded for one purchase | The webhook is retried by LemonSqueezy after a slow/failed response | Idempotency: dedupe by event_id in the webhook handler before insert |
| The welcome email never sends | It's inside the request path and the webhook times out first | Move the email into after() — the response is acknowledged, the email runs post-response |
| Buyers on a different account see each other's orders | Row-level security not scoping by session sub claim | Every 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 intermittently | Re-serializing the parsed body doesn't byte-match the original | Read 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.
