Building a Full-Stack Personalised Gifts E-Commerce Platform with Next.js 15
Building an e-commerce site is one of those projects that sounds simple until you actually start. Payments, authentication, inventory, personalisation, image handling, email notifications, admin tools, live chat — each piece is its own rabbit hole. I built PersonalisedGifts, a full UK-market personalised gifts store from scratch, wiring all of these together into a single cohesive product.
The site lets customers browse gifts by category (Mugs, Jewellery, Canvas Prints, Home Décor) or occasion (Birthday, Wedding, Christmas, New Baby), personalise their chosen item with custom text, images, fonts, or colours, and pay via Stripe (cards, Apple Pay, Google Pay, Klarna) or directly from their bank account via Tink’s open banking integration. It runs live on a Hostinger VPS at gift.devops-monk.com.
This post is the full technical walkthrough — architecture decisions, database design, payment integrations, deployment pipeline, and what I learned along the way.
What the Platform Does
At its core, PersonalisedGifts is an e-commerce store with one key differentiator: every product can be customised. A customer ordering a photo mug doesn’t just add it to their cart — they upload their photo, pick a layout, add a name, and see a preview before buying. A customer ordering an engraved keyring can type the message and choose the font. That personalisation data travels all the way through the order, gets stored in the database, and surfaces in the admin panel so the maker knows exactly what to produce.
The platform supports:
- Product catalogue with categories, occasions, variants (sizes, colours), and SEO fields
- Personalisation engine — per-product configuration for text inputs, image uploads, dropdowns, font pickers, colour pickers, and toggles
- Two payment methods — Stripe Checkout (cards, Apple Pay, Google Pay, Klarna) and Tink Pay by Bank (direct bank transfer via open banking, UK Faster Payments)
- Order management — full lifecycle from PENDING → PAID → PROCESSING → SHIPPED → DELIVERED, with per-item fulfilment tracking
- Admin dashboard — real-time stats, product CRUD with image upload, order management, customer listing
- Customer accounts — order history, wishlist, saved addresses, occasion reminders
- Gift options — gift wrapping, gift messages, and occasion-specific landing pages
- Live chat via Tawk.to for real-time customer support
- Transactional emails via Resend for order confirmations and shipping updates
Architecture Overview
flowchart TD
subgraph Client[Browser / Mobile]
NEXT[Next.js 15\nApp Router\nReact Server Components]
ZUSTAND[Zustand\nCart State]
FABRIC[Fabric.js\nPersonalisation Canvas]
end
subgraph Auth[Authentication]
CLERK[Clerk\nSign-in Sign-up\nSession Management\nWebhooks]
end
subgraph DB[Data Layer]
MYSQL[(MySQL 8.0\nPersonalised Gifts DB\n15 Tables)]
PRISMA[Prisma ORM\nServer Actions\nType-safe Queries]
end
subgraph Payments[Payment Providers]
STRIPE[Stripe Checkout\nCards Apple Pay\nGoogle Pay Klarna]
TINK[Tink Pay by Bank\nOpen Banking\nFaster Payments]
end
subgraph Media[Media and Comms]
CDN[Cloudinary\nImage CDN\nAuto WebP and AVIF]
EMAIL[Resend\nTransactional Emails]
CHAT[Tawk.to\nLive Chat Widget]
end
subgraph Infra[Infrastructure]
VPS[Hostinger VPS\nUbuntu 22.04]
NGINX[Nginx\nReverse Proxy\nSSL Termination]
PM2[PM2\nProcess Manager\nCluster Mode]
CERTBOT[Certbot\nLet Encrypt SSL]
end
NEXT -->|Server Actions| PRISMA
PRISMA --> MYSQL
NEXT -->|Clerk SDK| CLERK
CLERK -->|user.created webhook| PRISMA
NEXT -->|Stripe SDK| STRIPE
STRIPE -->|checkout.session.completed webhook| NEXT
NEXT -->|Tink API| TINK
TINK -->|payment:updated webhook| NEXT
NEXT -->|Upload API| CDN
NEXT -->|Order events| EMAIL
NEXT --> CHAT
NGINX --> NEXT
PM2 --> NEXT
VPS --> NGINX
VPS --> PM2
CERTBOT --> NGINX
The application is a Next.js 15 App Router project running in standalone mode on a Hostinger VPS. There is no serverless layer, no edge runtime, no Vercel — just a Node.js process managed by PM2, sitting behind Nginx which handles SSL termination and static asset caching.
Why a VPS instead of Vercel? A few reasons:
- The site uses MySQL rather than a serverless-compatible database. Prisma with MySQL requires a persistent connection pool, which is straightforward on a VPS and painful on Vercel’s serverless functions.
- Cost predictability. A £5/mo VPS is cheaper than Vercel Pro plus a managed database at any real traffic level.
- Full control. I can run Prisma Studio, SSH in to inspect logs, and deploy database migrations without juggling managed service constraints.
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 15 (App Router) | Full-stack React with server actions |
| Language | TypeScript | Type safety end-to-end |
| Database | MySQL 8.0 | Relational data store |
| ORM | Prisma | Type-safe database access |
| Auth | Clerk | User management and sessions |
| Payments (cards) | Stripe Checkout | Card, Apple Pay, Google Pay, Klarna |
| Payments (bank) | Tink Pay by Bank | Open banking, UK Faster Payments |
| Styling | Tailwind CSS v4 + shadcn/ui | Utility-first CSS with 56 UI components |
| State | Zustand | Cart persistence |
| Image CDN | Cloudinary | Auto-resize, WebP/AVIF, CDN delivery |
| Resend | Transactional emails | |
| Live Chat | Tawk.to | Real-time customer support |
| Canvas | Fabric.js | Interactive personalisation previews |
| Deployment | Nginx + PM2 on Hostinger VPS | Standalone Node.js in production |
Database Design
The schema has 15 tables modelling the full e-commerce domain. Here is how they relate:
erDiagram
User {
BigInt id
String clerkId
String email
Role role
}
Address {
BigInt id
BigInt userId
String line1
String city
String postalCode
}
Product {
Int id
String name
String slug
Decimal basePrice
ProductStatus status
Boolean isPersonalizable
}
Category {
Int id
String name
String slug
Int parentId
}
Occasion {
Int id
String name
String slug
}
ProductVariant {
Int id
Int productId
String sku
Decimal price
Int stockQty
}
PersonalizationOption {
Int id
Int productId
String optionKey
PersonalizationType optionType
Boolean isRequired
Decimal priceModifier
}
Order {
BigInt id
String orderNumber
OrderStatus status
Decimal totalAmount
String currency
String paymentMethod
Boolean isGift
}
OrderItem {
BigInt id
BigInt orderId
Int productId
Json personalizationData
String previewImageUrl
FulfillmentStatus fulfillmentStatus
}
Review {
Int id
Int productId
BigInt userId
Int rating
}
Wishlist {
Int id
BigInt userId
Int productId
}
Coupon {
Int id
String code
DiscountType discountType
Decimal discountValue
}
User ||--o{ Address : has
User ||--o{ Order : places
User ||--o{ Review : writes
User ||--o{ Wishlist : saves
Product }o--|| Category : belongs_to
Product ||--o{ ProductVariant : has
Product ||--o{ PersonalizationOption : configures
Product ||--o{ OrderItem : appears_in
Order ||--o{ OrderItem : contains
OrderItem }o--|| ProductVariant : uses
Key schema decisions
PersonalizationOption is per-product, not per-order-item. The product defines what personalisation it accepts (e.g., optionKey: "engraving_text", optionType: TEXT, isRequired: true). When a customer fills in their personalisation at checkout, the values are stored as a JSON blob in OrderItem.personalizationData. This keeps the schema flexible — a new personalisation field doesn’t require a migration, just a new row in PersonalizationOption.
OrderItem stores a snapshot. productSnapshot on OrderItem captures the product name, image, and price at time of purchase. This means if a product is later edited or deleted, the order history still shows exactly what was ordered. This is standard e-commerce practice but easy to forget during the build.
FulfillmentStatus is per item, not per order. An order of three items (a mug, a keyring, and a print) might have the mug dispatched before the keyring is finished. Tracking fulfilment at the line-item level gives the seller much more granular control.
Full-text index on Product. MySQL’s full-text search index on (name, description) powers the admin product search without needing Elasticsearch or a separate search layer.
Application Structure
src/
├── app/ # Next.js App Router
│ ├── admin/ # Admin dashboard (RBAC protected)
│ │ ├── products/ # Product listing, create, edit
│ │ ├── orders/ # Order management + fulfilment
│ │ └── customers/ # Customer listing
│ ├── account/ # Authenticated user pages
│ │ ├── orders/ # Order history
│ │ ├── wishlist/ # Saved products
│ │ └── addresses/ # Saved shipping addresses
│ ├── api/
│ │ ├── upload/ # Cloudinary upload endpoint (admin only)
│ │ └── webhooks/ # Stripe, Clerk, Tink webhook handlers
│ ├── cart/ # Cart page
│ ├── checkout/ # Checkout flow, payment return pages
│ ├── category/[slug]/ # Category landing pages
│ ├── occasion/[slug]/ # Occasion landing pages
│ └── products/ # Product listing and detail
├── components/
│ ├── admin/ # Admin-specific components
│ ├── layout/ # Header, footer
│ ├── product/ # Product card, personalisation form
│ └── ui/ # 56 shadcn/ui components
├── lib/
│ ├── actions/ # Server actions (the "API layer")
│ │ ├── admin.ts # Role guard: requireAdmin()
│ │ ├── admin-products.ts # Product CRUD with Prisma
│ │ ├── checkout.ts # Stripe checkout session creation
│ │ ├── tink-checkout.ts # Tink payment initiation
│ │ ├── products.ts # Public product queries
│ │ ├── user.ts # User + wishlist management
│ │ └── address.ts # Address CRUD
│ ├── db.ts # Prisma client singleton
│ ├── stripe.ts # Stripe server SDK
│ ├── tink.ts # Tink API helpers
│ └── cloudinary.ts # Cloudinary config
└── stores/
└── cart-store.ts # Zustand cart with localStorage
Why Server Actions instead of an API layer?
Next.js 15 Server Actions let you call server-side logic directly from React components — no REST endpoints, no fetch calls, no API route boilerplate. The checkout.ts server action creates a Stripe session and returns the redirect URL in a single function call. This keeps the checkout flow entirely in React without an intermediate API layer.
The tradeoff: server actions are tightly coupled to Next.js and harder to reuse outside it. For a project that will only ever have a Next.js frontend, this is the right call. For a project that might add a mobile app later, a conventional API layer would be wiser.
Payment Architecture
This was the most complex part of the build. The site supports two completely different payment flows.
Stripe — Card Payments
sequenceDiagram
participant Customer
participant Next.js
participant Stripe
participant Webhook
Customer->>Next.js: Click "Pay by Card"
Next.js->>Stripe: Create Checkout Session\n(line items, metadata, success/cancel URLs)
Stripe-->>Next.js: Session URL
Next.js-->>Customer: Redirect to Stripe-hosted page
Customer->>Stripe: Enter card details + pay
Stripe-->>Customer: Redirect to /checkout/success?session_id=...
Stripe->>Webhook: POST /api/webhooks/stripe\ncheckout.session.completed
Webhook->>Next.js: Verify signature, update Order status to PAID
Webhook->>Next.js: Send order confirmation email via Resend
Stripe Checkout is the hosted payment page approach — you redirect the customer to Stripe’s servers, they handle the card UI, PCI compliance, 3D Secure, Apple Pay, Google Pay, and Klarna. When payment succeeds, Stripe fires a checkout.session.completed webhook. The webhook handler verifies the signature, finds the order by session ID, and marks it PAID.
The metadata passed to Stripe on session creation includes the internal orderId — this is what ties the webhook back to the database record.
Tink — Pay by Bank (Open Banking)
sequenceDiagram
participant Customer
participant Next.js
participant Tink API
participant Customer's Bank
participant Tink Webhook
Customer->>Next.js: Click "Pay by Bank"
Next.js->>Tink API: POST /payments/initiate\n(amount, destination account, redirect URI)
Tink API-->>Next.js: Payment Request ID + Tink Link URL
Next.js-->>Customer: Redirect to Tink Link
Customer->>Tink API: Select bank, authenticate via banking app
Customer's Bank->>Tink API: Authorise payment via Faster Payments
Tink API-->>Customer: Redirect to /checkout/tink-return
Next.js->>Tink API: GET /payments/status (verify payment)
Tink Webhook->>Next.js: POST /api/webhooks/tink\npayment:updated event
Next.js->>Next.js: Update Order status to PAID
Tink’s Pay by Bank integration uses open banking — the customer authorises a payment directly from their bank account without entering card details. The flow:
- Server creates a payment initiation request via Tink’s API, specifying the destination account (sort code + account number), amount, and redirect URI.
- Tink Link (hosted by Tink) handles bank selection and authentication. On desktop, customers scan a QR code with their banking app. On mobile, it deep-links directly into their banking app.
- The bank processes the payment via UK Faster Payments (typically instant).
- Tink redirects the customer back to
/checkout/tink-returnwith the payment request ID. - The return page polls Tink’s API to confirm status, then a webhook confirms settlement.
Why two payment methods? Bank transfers have no processing fee (Stripe charges 1.5% + 20p per UK card transaction). For a £50 gift, that’s about £0.95 per sale. At scale, offering a fee-free bank transfer option meaningfully improves margin. Tink is also preferred by customers who don’t want to enter card details online.
Personalisation Engine
The personalisation system is what makes this more than a generic e-commerce template. Each product can have multiple PersonalizationOption rows:
| optionType | UI Component | Example use |
|---|---|---|
TEXT | Single-line input | Name, short message |
TEXTAREA | Multi-line input | Longer dedication text |
IMAGE | File upload via Cloudinary | Photo mug, canvas print |
COLOUR | Colour picker | Ribbon colour, text colour |
FONT | Font selector | Engraving font |
DROPDOWN | Select menu | Size of engraving, layout style |
TOGGLE | Checkbox | Add gift wrapping |
At product detail, these options render dynamically based on the product’s configuration. The canvas preview (Fabric.js) updates in real time as the customer fills in their personalisation — typing a name shows it on the mug, uploading a photo places it on the canvas print.
The constraints JSON field on each option allows per-option validation:
{
"maxLength": 20,
"allowedFonts": ["Script MT", "Arial", "Georgia"],
"maxFileSize": 5242880,
"aspectRatio": "1:1"
}
When the customer adds to cart, the personalisation data is serialised to OrderItem.personalizationData. The admin sees this data in the order detail view next to the item — exactly what text to engrave, which photo to print, which font to use.
Admin Panel
The admin panel at /admin is a full product management interface with role-based access control. Only users with role = ADMIN in the database can access it — the layout server-side redirects anyone else.
flowchart TD
Request[Request to /admin] --> AUTH{Clerk session\nvalid?}
AUTH -->|No| SIGNIN[Redirect to /sign-in]
AUTH -->|Yes| ROLE{DB role\n= ADMIN?}
ROLE -->|No| HOME[Redirect to /]
ROLE -->|Yes| ADMIN[Admin Dashboard]
ADMIN --> DASH[Dashboard\nRevenue Orders Products Customers\nRecent orders table]
ADMIN --> PROD[Products\nList with search and status filter\nCreate Edit Delete]
ADMIN --> ORD[Orders\nOrder detail\nFulfilment status per item]
ADMIN --> CUST[Customers\nAll registered users]
PROD --> UPLOAD[Image Upload\nDrag and drop to Cloudinary\nSet primary image\nReorder gallery]
The product form handles both create and edit — it’s a single shared component (ProductForm) that receives either an empty object or an existing product. Image upload is via a dedicated API route (/api/upload) that validates the Clerk session and admin role before proxying to Cloudinary. Drag-and-drop reordering updates ProductImage.sortOrder. Deleting a product cleans up the Cloudinary assets via the Admin API before removing the database record.
Dashboard stats are a single Prisma query using $transaction — revenue (sum of order totals), order count, product count, and customer count, computed in parallel:
const [totalRevenue, orderCount, productCount, customerCount] = await prisma.$transaction([
prisma.order.aggregate({ _sum: { totalAmount: true }, where: { status: 'PAID' } }),
prisma.order.count(),
prisma.product.count({ where: { status: 'ACTIVE' } }),
prisma.user.count({ where: { role: 'CUSTOMER' } }),
]);
Image Pipeline
Product images go through a defined pipeline:
flowchart LR
UPLOAD[Admin uploads image\ndrag and drop] --> API[Upload API endpoint\nvalidates session and role]
API --> CDN[Cloudinary\nstores original]
CDN -->|Auto transform| WEBP[WebP and AVIF\nauto-format auto-quality]
CDN -->|Resize on demand| THUMB[400x400 crop fill\nproduct thumbnails]
CDN -->|Resize on demand| DETAIL[800x800 crop fill\nproduct detail]
CDN --> DB[URL stored in\nProductImage table]
DB --> NEXT[Next.js Image component\nserves optimised URL]
Cloudinary handles format negotiation — the browser requests a URL and gets WebP on modern browsers or JPEG on older ones, without any code changes. The admin uploads once; the CDN handles all resizing. This is particularly important for personalised products where customers upload photos — Cloudinary validates dimensions and converts to a consistent format before it ever hits the database.
Authentication Flow
Clerk handles the entire auth surface — sign up, sign in, social login (Google, Apple), email verification, and session tokens. Integration is via middleware and the Clerk React SDK.
The Clerk webhook (/api/webhooks/clerk) listens for user.created and user.updated events. When a new user signs up via Clerk, the webhook creates a corresponding User row in MySQL. This keeps the Clerk identity store and the application database in sync — the clerkId field is the join key.
// src/app/api/webhooks/clerk/route.ts
const event = await verifyWebhook(request, headers, process.env.CLERK_WEBHOOK_SECRET);
if (event.type === 'user.created') {
await prisma.user.create({
data: {
clerkId: event.data.id,
email: event.data.email_addresses[0].email_address,
name: `${event.data.first_name} ${event.data.last_name}`.trim(),
},
});
}
Role-based access is checked server-side in every admin action:
// src/lib/actions/admin.ts
export async function requireAdmin() {
const { userId } = await auth();
if (!userId) redirect('/sign-in');
const user = await prisma.user.findUnique({ where: { clerkId: userId } });
if (!user || user.role !== 'ADMIN') redirect('/');
return user;
}
Deployment on Hostinger VPS
The production stack runs on a Hostinger KVM VPS (Ubuntu 22.04, 2GB RAM):
flowchart TD
INTERNET[Internet\nHTTPS port 443] --> CERTBOT[Let Encrypt SSL\nCertbot auto-renewal]
CERTBOT --> NGINX[Nginx\nReverse Proxy\nStatic asset cache\nGzip compression]
NGINX -->|proxy_pass localhost:3000| PM2[PM2 Cluster\nMultiple Node.js processes]
PM2 --> NEXT[Next.js Standalone\n.next/standalone/server.js]
NEXT --> PRISMA[Prisma Client]
PRISMA --> MYSQL[(MySQL 8.0\nlocalhost:3306)]
Standalone build. next build with output: 'standalone' in next.config.ts produces a self-contained Node.js application in .next/standalone/. It includes only the necessary Node modules — no node_modules folder required at runtime. Static assets and public files are copied separately.
PM2 cluster mode. Running with -i max starts one process per CPU core. On a 2-core VPS, this means two Node.js processes sharing the load. PM2 restarts crashed processes automatically and persists the process list across reboots via pm2 save + pm2 startup.
Nginx configuration. Nginx handles SSL termination (Certbot, Let’s Encrypt, auto-renewing every 90 days), serves /_next/static assets with a 1-year Cache-Control: immutable header (Next.js content-hashes these files so they are safe to cache forever), and proxies all other requests to PM2.
Deployment workflow
For a code-only deploy:
git pull origin main
npm install
npm run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
pm2 restart personalised-gifts
For a schema change deploy:
# Locally — create migration
npx prisma migrate dev --name add-gift-notes-field
git add prisma/migrations && git push
# On VPS — apply migration
git pull origin main
npx prisma migrate deploy
npx prisma generate
npm run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
pm2 restart personalised-gifts
The discipline of using migrate deploy on the VPS (not migrate dev) matters — migrate dev needs shadow database access and creates migration files, while migrate deploy only applies existing migration files and is safe to run in production.
What I Learned
Personalisation data is harder than it looks. Storing it as JSON is flexible, but displaying it usefully requires discipline. The admin needs to see “Name: Sophie, Font: Script MT, Colour: #d4a017” — not a raw JSON object. I ended up with a small formatPersonalisation(data, options) utility that joins the option labels with their values for human-readable display.
Webhooks need idempotency. Both Stripe and Tink can fire the same webhook event more than once (at-least-once delivery). The webhook handler checks if the order is already PAID before updating — a simple guard that prevents double-processing without needing a dedicated idempotency key table.
Tink sandbox is excellent. The Demo Bank in Tink’s sandbox environment simulates the entire bank authentication flow end-to-end, including the QR code and banking app redirect. I could test the complete Pay by Bank journey without real bank accounts or real money.
MySQL full-text search is good enough. I considered Algolia for product search. For 25 products in development and realistic SKU counts under 1,000, MySQL’s built-in full-text index on (name, description) performs perfectly well. Adding a dedicated search service would be premature optimisation.
Cloudinary’s free tier goes fast. The 25 credits/month free tier covers about 25GB of bandwidth. Product images served to real users can hit this sooner than expected. For a live store with meaningful traffic, budgeting for paid Cloudinary usage (or implementing aggressive browser caching) matters.
Services and Costs
| Service | What it does | Free tier | Production cost |
|---|---|---|---|
| Clerk | Auth and user management | 10,000 MAU/mo | $25/mo at 10K+ MAU |
| Stripe | Card payments | No monthly fee | 1.5% + 20p per UK card |
| Tink | Pay by Bank (open banking) | Free sandbox | Enterprise pricing |
| Cloudinary | Image storage + CDN | 25 credits/mo | Pay-as-you-go |
| Resend | Transactional email | 3,000 emails/mo | $20/mo for 50K |
| Tawk.to | Live chat | Completely free | Free forever |
| Hostinger VPS | Compute + MySQL | — | ~£5–15/mo |
The entire stack can run within free tiers for development and low-volume early launch. The only mandatory ongoing cost is the VPS.
Source Code
The full source is available on GitHub. The project is structured to be deployable by anyone — the README includes a step-by-step guide to setting up every third-party service, configuring environment variables, running migrations, and deploying to a VPS.
Stack summary:
- Next.js 15 (App Router, Server Actions, standalone output)
- TypeScript end-to-end
- MySQL 8.0 + Prisma (15 tables, full-text search, migrations)
- Clerk (auth, webhooks, RBAC)
- Stripe Checkout + Tink Pay by Bank (dual payment methods)
- Cloudinary (image CDN with auto-format)
- Resend (transactional email)
- Tawk.to (live chat)
- Tailwind CSS v4 + shadcn/ui (56 components)
- Zustand (cart state)
- Fabric.js (personalisation canvas)
- PM2 + Nginx + Certbot on Hostinger VPS
If you are building something similar — a personalised gifts store, a print-on-demand platform, or any product that needs both card and bank transfer payments in the UK — this architecture is a solid starting point. The combination of Next.js Server Actions, Prisma, and Clerk eliminates a huge amount of boilerplate while keeping the stack easy to reason about and deploy.
