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:

  1. 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.
  2. Cost predictability. A £5/mo VPS is cheaper than Vercel Pro plus a managed database at any real traffic level.
  3. Full control. I can run Prisma Studio, SSH in to inspect logs, and deploy database migrations without juggling managed service constraints.

Tech Stack

LayerTechnologyPurpose
FrameworkNext.js 15 (App Router)Full-stack React with server actions
LanguageTypeScriptType safety end-to-end
DatabaseMySQL 8.0Relational data store
ORMPrismaType-safe database access
AuthClerkUser management and sessions
Payments (cards)Stripe CheckoutCard, Apple Pay, Google Pay, Klarna
Payments (bank)Tink Pay by BankOpen banking, UK Faster Payments
StylingTailwind CSS v4 + shadcn/uiUtility-first CSS with 56 UI components
StateZustandCart persistence
Image CDNCloudinaryAuto-resize, WebP/AVIF, CDN delivery
EmailResendTransactional emails
Live ChatTawk.toReal-time customer support
CanvasFabric.jsInteractive personalisation previews
DeploymentNginx + PM2 on Hostinger VPSStandalone 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:

  1. Server creates a payment initiation request via Tink’s API, specifying the destination account (sort code + account number), amount, and redirect URI.
  2. 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.
  3. The bank processes the payment via UK Faster Payments (typically instant).
  4. Tink redirects the customer back to /checkout/tink-return with the payment request ID.
  5. 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:

optionTypeUI ComponentExample use
TEXTSingle-line inputName, short message
TEXTAREAMulti-line inputLonger dedication text
IMAGEFile upload via CloudinaryPhoto mug, canvas print
COLOURColour pickerRibbon colour, text colour
FONTFont selectorEngraving font
DROPDOWNSelect menuSize of engraving, layout style
TOGGLECheckboxAdd 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

ServiceWhat it doesFree tierProduction cost
ClerkAuth and user management10,000 MAU/mo$25/mo at 10K+ MAU
StripeCard paymentsNo monthly fee1.5% + 20p per UK card
TinkPay by Bank (open banking)Free sandboxEnterprise pricing
CloudinaryImage storage + CDN25 credits/moPay-as-you-go
ResendTransactional email3,000 emails/mo$20/mo for 50K
Tawk.toLive chatCompletely freeFree forever
Hostinger VPSCompute + 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.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.