Build a Complete Digital Product Store with Next.js (Full Project + Demo Checkout)

In this comprehensive guide, we’ll build a complete digital product store using Next.js 16, Prisma, and JWT authentication — step by step. Users will be able to register, log in, browse products, simulate checkout (no Stripe required), and download their digital assets via a real “My Downloads” page.

⚠️ Note: The full project code is too large to include here. You can download it directly from Google Drive:

👉 Download Full Project Code (Google Drive)

Use it to explore the complete implementation, database setup, and API routes.


Tech Stack

  • Next.js 16 (App Router) – Full-stack framework (frontend + backend)
  • Prisma ORM – Database toolkit
  • SQLite / PostgreSQL – Persistent storage
  • React Query (@tanstack/react-query) – Client-side data fetching
  • JWT (jsonwebtoken) – Token-based authentication
  • Tailwind CSS – Styling

Step 1: Setup the Project

Start a new Next.js project and install dependencies:

npx create-next-app@latest my-app
cd my-app
npm install prisma @prisma/client jsonwebtoken bcryptjs @tanstack/react-query axios
npm install --save-dev ts-node typescript
npx prisma init

Step 2: Configure Prisma Schema

Edit your prisma/schema.prisma to define the data models:

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id         Int         @id @default(autoincrement())
  name       String
  email      String      @unique
  password   String
  purchases  Purchase[]
  createdAt  DateTime    @default(now())
}

model Product {
  id          Int         @id @default(autoincrement())
  title       String
  slug        String      @unique
  description String?
  priceCents  Int
  currency    String
  filePath    String
  isPublished Boolean      @default(true)
  createdAt   DateTime     @default(now())
  purchases   Purchase[]
}

model Purchase {
  id          Int      @id @default(autoincrement())
  userId      Int
  productId   Int
  amountCents Int
  currency    String
  status      String
  licenseKey  String
  createdAt   DateTime @default(now())
  product     Product  @relation(fields: [productId], references: [id])
  user        User     @relation(fields: [userId], references: [id])
}

Then migrate:

npx prisma migrate dev --name init

Step 3: Authentication with JWT

We’ll create two API routes — register and login — that use bcrypt for password hashing and JWT for authentication.

src/pages/api/auth/register.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
import jwt from 'jsonwebtoken'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })

  const { name, email, password } = req.body
  const existing = await prisma.user.findUnique({ where: { email } })
  if (existing) return res.status(400).json({ error: 'Email already registered' })

  const hashed = await bcrypt.hash(password, 10)
  const user = await prisma.user.create({ data: { name, email, password: hashed } })

  const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET || 'secret', { expiresIn: '7d' })
  res.status(200).json({ token })
}

src/pages/api/auth/login.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
import jwt from 'jsonwebtoken'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })

  const { email, password } = req.body
  const user = await prisma.user.findUnique({ where: { email } })
  if (!user) return res.status(400).json({ error: 'Invalid credentials' })

  const valid = await bcrypt.compare(password, user.password)
  if (!valid) return res.status(400).json({ error: 'Invalid credentials' })

  const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET || 'secret', { expiresIn: '7d' })
  res.status(200).json({ token })
}

Step 4: Seeding Demo Products

Create a prisma/seed.ts script:

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function main() {
  await prisma.product.createMany({
    data: [
      {
        title: 'UI Kit Starter',
        slug: 'ui-kit-starter',
        description: 'A clean and modern UI kit for developers',
        priceCents: 1500,
        currency: 'usd',
        filePath: 'ui-kit-starter.zip',
      },
      {
        title: 'Mastering CSS Ebook',
        slug: 'mastering-css',
        description: 'A complete guide to modern CSS',
        priceCents: 900,
        currency: 'usd',
        filePath: 'mastering-css.pdf',
      },
    ],
  })
}
main().finally(() => prisma.$disconnect())

Run:

npm run prisma:seed

Step 5: Demo Checkout (No Stripe)

We’ll simulate payments by directly creating a Purchase entry in the database.

src/pages/api/purchase/create-session.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import jwt from 'jsonwebtoken'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
  const auth = req.headers.authorization
  if (!auth) return res.status(401).json({ error: 'No token' })

  const token = auth.split(' ')[1]
  const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret') as any
  const userId = decoded.id

  const { productId } = req.body
  const product = await prisma.product.findUnique({ where: { id: productId } })
  if (!product) return res.status(404).json({ error: 'Product not found' })

  const purchase = await prisma.purchase.create({
    data: {
      userId,
      productId,
      amountCents: product.priceCents,
      currency: product.currency,
      status: 'complete',
      licenseKey: `LIC-${Math.random().toString(36).substring(2, 10).toUpperCase()}`,
    },
    include: { product: true },
  })

  res.status(200).json({ redirect: '/downloads', purchase })
}

Step 6: My Downloads API

src/pages/api/purchase/list.ts

import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import jwt from 'jsonwebtoken'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' })

  const auth = req.headers.authorization
  if (!auth) return res.status(401).json({ error: 'No token' })

  const token = auth.split(' ')[1]
  const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret') as any
  const userId = decoded.id

  const purchases = await prisma.purchase.findMany({
    where: { userId },
    include: { product: true },
    orderBy: { createdAt: 'desc' },
  })

  res.status(200).json({ items: purchases })
}

Step 7: Frontend Pages

Home Page

Displays products with “Buy Now” buttons fetched via /api/products.

Checkout Page

Calls the demo checkout API and redirects to /downloads.

Downloads Page

Fetches /api/purchase/list and lists purchased products with download links from /public/files.


Step 8: Fix Hydration Issues

Always call hooks in the same order and use enabled in useQuery:

const { data } = useQuery({ queryFn, enabled: mounted && !!token })

Step 9: Test Everything

  1. npm run dev
  2. Register & login
  3. Buy a product → Simulate payment
  4. Go to My Downloads
  5. Download demo files 🎉

Final Features

  • JWT-based authentication
  • Prisma ORM integration
  • Product management
  • Demo checkout (no Stripe)
  • Downloadable content
  • Hydration-safe React Query

Conclusion

You’ve just built a complete digital product store using Next.js, Prisma, and JWT — with authentication, fake payments, and downloadable assets.

💾 Download Full Project Code (Google Drive)

Enjoy coding your own Gumroad-style digital store!