A fullstack fashion storefront with PayOS checkout
A Next.js 14 fashion storefront: stock held per colour and size variant, a persisted Redux cart, Auth.js sign-in, and PayOS checkout confirmed by a signed webhook.
- Next.js 14
- PostgreSQL
- Prisma
- Auth.js
- PayOS
A fashion storefront built on the Next.js 14 App Router: a product catalogue, a cart, sign-in, and checkout through the PayOS gateway, with PostgreSQL reached via Prisma.
Stock belongs to the variant, not the product#
The first schema decision shaped everything above it: a shirt does not have a stock
count. The thing that actually has stock is that shirt, in black, in size M. Put
stock on the Product table and every layer above it ends up lying.
model Product {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(255)
price Decimal @db.Decimal(15, 2)
category_id Int
variants ProductVariant[]
}
model ProductVariant {
id Int @id @default(autoincrement())
product_id String @db.Uuid
color String @db.VarChar(50)
sizes ProductVariantSize[]
images ProductVariantImage[]
}
model ProductVariantSize {
id Int @id @default(autoincrement())
variant_id Int
size String @db.VarChar(20)
/* Stock lives here: one row per colour-and-size pair. */
stock Int @default(0)
}Images hang off the variant rather than the product, so switching colour swaps the whole
image set without another request — /api/products already includes all three levels,
variants, sizes and images, in a single query. Prices are Decimal(15, 2) rather than
Float: round money in binary and it drifts eventually.
The cart is client state, the order is not#
The cart is a Redux Toolkit slice persisted to the browser, so closing a tab does not
empty it. A cart line is keyed by the triple productId, variantId and size —
adding the same shirt in two sizes has to produce two lines, not one line with a bigger
quantity.
Orders go the other way: the server writes them. POST /api/orders validates the whole
payload with Zod, checks every product_id really exists, checks the payment method is
still is_active, then creates the order and its OrderItem rows in one nested
prisma.order.create. The order ID comes from the client via crypto.randomUUID() and
the server rejects a duplicate with a 409, so a double-clicked button cannot become two
orders.
Confirmation belongs to the webhook#
This is the part of a storefront worth getting right, and the easiest part to get wrong.
When the fast-transfer method is chosen, the client calls
/api/payments/payos/create-link to build a payment link — orderCode has to be an
integer and PayOS caps description at 25 characters, so a small helper trims the order
UUID to fit — and then redirects the buyer to the gateway.
From that moment the buyer's browser is no longer a trustworthy source. They can close the tab, lose signal, or type the success URL by hand. The source of truth is the request PayOS sends straight to the server:
export async function POST(req) {
const payload = await req.json()
/* Verify the signature before reading a single field — without this step,
anyone can POST a "paid" order. */
const verified = payos.webhooks.verify(payload)
if (!verified) {
return NextResponse.json({ ok: false, error: 'INVALID_SIGNATURE' }, { status: 400 })
}
const data = payload?.data || {}
const orderCode = String(data?.orderCode || '')
const statusFromGateway = String(data?.status || '').toUpperCase()
const order = await prisma.order.findFirst({
where: { payos_order_code: orderCode },
select: { id: true, status: true },
})
/* 200 with an error flag: a bad mapping is my bug, not a reason for PayOS to
retry forever. */
if (!order) {
return NextResponse.json({ ok: false, error: 'ORDER_NOT_FOUND' }, { status: 200 })
}
let newStatus = null
if (statusFromGateway === 'PAID') newStatus = 'CONFIRMED'
else if (statusFromGateway === 'CANCELLED') newStatus = 'CANCELLED'
else return NextResponse.json({ ok: true, skipped: true })
/* Idempotent: the gateway may call again, so only write on a real change. */
if (order.status !== newStatus) {
await prisma.order.update({ where: { id: order.id }, data: { status: newStatus } })
}
return NextResponse.json({ ok: true, orderId: order.id, status: newStatus })
}The /checkout/success page does call a handle-return route that reconciles the
lastOrderId kept in localStorage against the gateway's query string, but its job
should be reassuring the buyer, not deciding whether an order was paid for.
Gatekeeping at the middleware#
middleware.js is wrapped directly in Auth.js's auth(), so every access check runs at
the edge before a page does any work. Role is read from the JWT first and the session
second, because on a fast navigation the session may not have hydrated yet. Everything
under /admin and /api/admin requires the admin role, while /checkout/* gives a
signed-out visitor a 30-second grace period tracked by an httpOnly timestamp cookie
before bouncing them home. Sign-in has two paths: Google OAuth, and email plus a
bcrypt-hashed password.
Outcome#
- A catalogue with colour-and-size variants carrying their own stock and images, read in one query
- A complete PayOS flow: link creation, webhook signature verification, gateway-to-
OrderStatusmapping, idempotent updates - Runs from a single
docker compose up, withprisma generatebaked into the image build - Real limitations: the missing
payos_order_codecolumn means the webhook cannot match an order yet;POST /api/ordersstill trusts anx-user-idheader instead of the server session; prices come from the client payload rather than being re-read from the database; and creating an order does not decrement variantstock