By Jake Mcdonald, founder of ShipEmber · November 2, 2026 · About a 12-minute read · Snippets checked against Auth.js and Stripe docs
I'm Jake Mcdonald. This is the checklist I wish I'd had the first time I wired Next.js App Router + Auth.js + Stripe subscriptions end to end. It complements my longer webhook-focused guide — How to add Stripe subscriptions to a Next.js App Router app — and focuses on the full setup order so you don't discover a missing env var after your first test charge.
Snippets below match current Auth.js installation/protecting docs and Stripe Checkout / Billing Portal / webhook verification patterns as of November 2, 2026. Always re-check authjs.dev and docs.stripe.com if an API moved.
The checklist (print this)
- App Router project + Postgres + Prisma (or your ORM)
- Auth.js v5 installed:
auth.ts, route handler, session on the server - At least one provider working locally (Google, GitHub, or magic link)
- Stripe Product + recurring Price; secrets in env (never in the client)
- Checkout Session route that reuses a Stripe Customer
- Webhook route: raw body → signature verify → idempotent event store → plan sync
- Server-side Free/Pro gate (not a hidden button)
- Customer portal session for cancel/upgrade/card changes
- Three webhook secrets mentally labeled: CLI / test / live (and Preview vs Production on Vercel)
- One live-mode test charge before you tweet the launch
1. Auth.js v5 on App Router
Auth.js docs currently install with next-auth@beta for the Auth.js v5 line:
npm install next-auth@beta
npx auth secret # writes AUTH_SECRET// auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { db } from "@/lib/db"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
Google,
GitHub,
// add Resend/Nodemailer magic-link provider when you're ready
],
callbacks: {
session({ session, user }) {
if (session.user) session.user.id = user.id
return session
},
},
})// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlersProtect a page by calling auth() on the server (from Auth.js "Protecting" docs):
// app/dashboard/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/api/auth/signin")
return <h1>Hi {session.user.email}</h1>
}Note on middleware vs proxy: Auth.js docs say that as of Next.js 16, middleware.ts is renamed to proxy.ts (export { auth as proxy }). On older Next.js, keep middleware.ts with export { auth as middleware }. Either way, do not rely on the edge check alone — Auth.js docs are explicit that you still verify the session close to your data.
2. Stripe products and env
- Create a Product with a recurring Price in the Stripe Dashboard. Copy
price_.... - Env vars (server only for secrets):
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_PRO,AUTH_SECRET,APP_URL. - Shared client:
// lib/stripe.ts
import "server-only"
import Stripe from "stripe"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)Recent stripe-node releases pin a default API version — you usually omit apiVersion unless you have a reason.
3. Checkout Session (subscription mode)
// app/api/checkout/route.ts
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { stripe } from "@/lib/stripe"
import { db } from "@/lib/db"
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id } })
let customerId = user.stripeCustomerId
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email!,
metadata: { userId: user.id },
})
customerId = customer.id
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
})
}
const checkout = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
client_reference_id: user.id,
line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
subscription_data: { metadata: { userId: user.id } },
success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.APP_URL}/pricing`,
})
return NextResponse.json({ url: checkout.url })
}Checklist reminders: reuse the Customer; put userId on subscription metadata; treat the success URL as a thank-you page, not as proof of payment.
4. Webhook: raw body, signature, idempotency
Stripe signs the exact bytes. In App Router Route Handlers, read await request.text() and pass that string to constructEvent (use constructEventAsync on Edge). Never req.json() first.
// app/api/stripe/webhook/route.ts
import { headers } from "next/headers"
import { stripe } from "@/lib/stripe"
import { db } from "@/lib/db"
import { syncSubscription } from "@/lib/billing"
export const runtime = "nodejs"
export async function POST(request: Request) {
const body = await request.text()
const sig = (await headers()).get("stripe-signature")
if (!sig) return new Response("Missing signature", { status: 400 })
let event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response("Bad signature", { status: 400 })
}
// Idempotency: skip events you've already applied
const seen = await db.stripeEvent.findUnique({ where: { id: event.id } })
if (seen) return new Response("ok", { status: 200 })
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.updated":
case "customer.subscription.deleted":
case "invoice.paid":
case "invoice.payment_failed":
await syncSubscription(event)
break
}
await db.stripeEvent.create({
data: { id: event.id, type: event.type },
})
return new Response("ok", { status: 200 })
}Minimum events most Free/Pro apps care about: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, plus invoice paid/failed if you show past_due states. Store evt_... IDs so Stripe retries don't double-apply.
5. Gate Pro on the server
// lib/billing.ts
export function isPro(user: { subscriptionStatus: string | null }) {
return user.subscriptionStatus === "active"
|| user.subscriptionStatus === "trialing"
}
// app/pro/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import { db } from "@/lib/db"
import { isPro } from "@/lib/billing"
export default async function ProPage() {
const session = await auth()
if (!session?.user?.id) redirect("/api/auth/signin")
const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id } })
if (!isPro(user)) redirect("/pricing")
return <div>Pro content</div>
}6. Customer portal
// app/api/billing/portal/route.ts
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { stripe } from "@/lib/stripe"
import { db } from "@/lib/db"
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id } })
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No customer" }, { status: 400 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/dashboard`,
})
return NextResponse.json({ url: portal.url })
}Enable the portal features you want in the Stripe Dashboard (cancel, plan switch, payment method update). Your webhook still owns plan state when those changes happen.
7. Local / test / live webhook secrets
- Local:
stripe listen --forward-to localhost:3000/api/stripe/webhook→ CLI-printed secret - Vercel Preview / Stripe test mode: Dashboard endpoint secret for your preview URL
- Production / live mode: separate endpoint + secret
Mixing those three is the #1 "signature verification failed" cause I see. Label the env vars in your head even if the name is the same across environments.
Why ShipEmber (if you don't want to wire this twice)
This checklist is doable in a focused weekend. It's also the exact loop ShipEmber already ships: Auth.js v5, Stripe Checkout + portal + idempotent webhooks, Prisma/Postgres, Free/Pro gating, marketing pages, tests/CI. Live price: $49 launch week, then $79 on pricing. Instant download, 14-day refund if it doesn't work as described.
If you'd rather own every line, keep this page open and build it. If you'd rather spend the weekend on the product, use the kit.
Ship the boring path
Longer Stripe webhook guide · Free vs paid boilerplates · ShipEmber docs
Related: Boilerplate comparison · ShipFast alternative · Why I built ShipEmber
Doc note: Auth.js install (next-auth@beta), auth() protecting, and Next.js 16 proxy rename checked on authjs.dev on November 2, 2026. Checkout mode: "subscription", raw-body constructEvent, and Billing Portal sessions match Stripe's public docs the same day.
— Jake Mcdonald, Springfield, IL