Build an Online Shop with Next.js #1: Getting Started and Design

9 min read

In the previous series we built a blog with Next.js. Its final post recommended an online shop as the next challenge — and this series is exactly that. We start from the same App Router, but the problems have a different character. The blog was all about reading; a shop has to handle user state (the cart), transactions (creating orders), and persisted results (order lookup). It’s the domain where the division of labor between Server Components and Server Actions emerges most naturally.

We’ll build it up gradually across 5 posts.

  • #1 Getting Started and Design ← this post
  • #2 Product catalog (list / detail / filters)
  • #3 Shopping cart (Client Context + localStorage)
  • #4 Checkout and payment simulation
  • #5 Order confirmation page and deployment

Define the requirements #

Before writing any code, let’s write down what our shop needs to be able to do.

Core features

  • Product list page (all products / category filter)
  • Product detail page (image, description, price, add to cart)
  • Cart page (change quantities, remove items, show totals)
  • Checkout page (shipping info form, payment flow)
  • Order confirmation page (order number, order details)

Non-functional requirements

  • Cart survives page refreshes (localStorage)
  • On payment failure, tell the user and allow retry
  • Layout that works on mobile
  • Search-engine-friendly URL structure

Tech decisions (pin these down up front to avoid wavering) #

Just as in the blog series, we nail down the big decisions first — so we don’t have to revisit “how should we do this?” in every post.

  • Framework: Next.js App Router (Server Components / Server Actions)
  • Product data: JSON file (no DB; keeps learning simple)
  • Cart: Client Context + localStorage (no server sessions)
  • Order store: a module-scoped in-memory Map (resets on server restart; a real service needs a DB, but we’re simplifying for learning)
  • Payments: simulated (no external payment gateway — fake results split 80% success / 20% failure)
  • Deploy on Vercel

One line of reasoning for each decision.

DecisionWhy
App RouterStays in the same paradigm as the blog series. The area where RSC + Actions shine brightest
JSON product dataRemoves DB setup from the learning load; focus on routing, rendering, and form logic
Context + localStorageThe standard pattern for guest-checkout shops. Server sessions would drag in authentication
In-memory order storeShows the “order gets created, then appears on a result page” flow with the simplest possible code
Payment simulationLearn the branching of a payment flow without gateway signup, API keys, or webhook setup

In the final post, I’ll point out where each piece would be swapped for a production-grade tool.

Component and route design #

Sketch the route tree first, and the folder structure falls out naturally.

route tree
/                        ← Home (bestsellers / category entry points)
/products                ← Product list (filtered via ?category=...)
/products/[slug]         ← Product detail
/cart                    ← Shopping cart
/checkout                ← Shipping info + payment
/orders/[orderId]        ← Order confirmation / lookup

Categories are expressed via searchParams, as in /products?category=apparel. Why a query string instead of a dynamic [category] folder? Because a category is essentially one kind of search filter, and the design extends naturally when other filters like price range or sort order come along. Contrast this with blog series #3, where tags got their own /tags/[tag] dynamic folder — there, “the tag itself was a page,” whereas a shop category is “a condition that narrows the product list.”

The component tree varies per page, but the big picture looks like this.

shared layout
RootLayout
└── CartProvider             — cart state (Client Context)
    ├── Header               — logo / category links / cart icon (quantity badge)
    ├── {children}           — each page
    └── Footer

Since the cart state is referenced by the Header on nearly every page (the icon badge), placing the Provider right under RootLayout is the natural choice.

Product data model #

Decide what information each product holds. It’s a shape that fits in a single JSON entry.

one entry in data/products.json
{
  "slug": "minimal-tee-black",
  "name": "Minimal Tee (Black)",
  "category": "apparel",
  "price": 24000,
  "description": "100% cotton. Plain design. Goes with everything.",
  "image": "/images/products/minimal-tee-black.jpg",
  "stock": 42
}

What each field means:

FieldTypeDescription
slugstringURL identifier. The slug in /products/[slug]
namestringProduct name
categorystringCategory ID (apparel, goods, book, etc.)
pricenumberPrice in KRW (integer, tax included)
descriptionstringProduct description (plain text)
imagestringImage path relative to public/
stocknumberInventory. 0 means shown as sold out

Just like the blog’s frontmatter, pinning down the shape of the data first makes writing the code far easier.

Order data model #

An order is created the moment payment completes. It looks like this.

shape of an order object
{
  id: 'ord_1748124000_a8c2',
  createdAt: '2026-05-25T10:00:00.000Z',
  items: [
    { slug: 'minimal-tee-black', name: 'Minimal Tee (Black)', price: 24000, quantity: 2 },
    { slug: 'mug-white',         name: 'Mug (White)',         price: 12000, quantity: 1 },
  ],
  total: 60000,
  shipping: {
    name: 'John Doe',
    phone: '555-0100',
    address: '123 Somewhere St, Springfield',
  },
  status: 'paid',
}

The id is issued by the server right after payment. For status we’ll only deal with paid for now, but a real service would add states like pending / failed / shipped.

Create the project #

Let’s get into real code.

create the project
npx create-next-app@latest my-shop
cd my-shop

Choose App Router, JavaScript, the src directory, and Tailwind. These are the same options as the blog series, and Tailwind is handy for polishing the catalog screens quickly.

Folder structure #

Laying out the folders with the big picture in mind up front means less shuffling things around later.

project structure
my-shop/
├── data/
│   └── products.json            ← product seed data
├── public/
│   └── images/
│       └── products/            ← product images
├── src/
│   └── app/
│       ├── layout.js            ← RootLayout + CartProvider
│       ├── page.js              ← '/' home
│       ├── products/
│       │   ├── page.js          ← '/products' list
│       │   └── [slug]/
│       │       └── page.js      ← '/products/[slug]' detail
│       ├── cart/
│       │   └── page.js          ← '/cart' shopping cart
│       ├── checkout/
│       │   └── page.js          ← '/checkout' checkout
│       ├── orders/
│       │   └── [orderId]/
│       │       └── page.js      ← '/orders/[orderId]' order confirmation
│       └── lib/
│           ├── products.js      ← product lookup utilities
│           └── orders.js        ← order save/lookup utilities
└── package.json

The separation between data/ and src/app/lib/ is deliberate. data/ is content (material that changes on its own schedule), while lib/ is the code that handles that data. It’s the same pattern as splitting posts/ and src/app/lib/posts.js in the blog series.

Product seed data #

Put a handful of products into data/products.json. We’ll use them to fill the catalog screens in #2.

data/products.json
[
  {
    "slug": "minimal-tee-black",
    "name": "Minimal Tee (Black)",
    "category": "apparel",
    "price": 24000,
    "description": "100% cotton. Plain design. Goes with everything.",
    "image": "/images/products/minimal-tee-black.jpg",
    "stock": 42
  },
  {
    "slug": "minimal-tee-white",
    "name": "Minimal Tee (White)",
    "category": "apparel",
    "price": 24000,
    "description": "Same fabric as the black, in white.",
    "image": "/images/products/minimal-tee-white.jpg",
    "stock": 38
  },
  {
    "slug": "mug-white",
    "name": "Mug (White)",
    "category": "goods",
    "price": 12000,
    "description": "A chunky ceramic mug. Dishwasher safe.",
    "image": "/images/products/mug-white.jpg",
    "stock": 80
  },
  {
    "slug": "tote-canvas",
    "name": "Canvas Tote Bag",
    "category": "goods",
    "price": 18000,
    "description": "An everyday canvas tote. Fits A4 with room to spare.",
    "image": "/images/products/tote-canvas.jpg",
    "stock": 25
  },
  {
    "slug": "react-book",
    "name": "Practical React Guide",
    "category": "book",
    "price": 32000,
    "description": "Practical React and Next.js patterns in one volume.",
    "image": "/images/products/react-book.jpg",
    "stock": 0
  },
  {
    "slug": "ts-book",
    "name": "TypeScript Primer",
    "category": "book",
    "price": 28000,
    "description": "TypeScript from the basics through generics.",
    "image": "/images/products/ts-book.jpg",
    "stock": 15
  }
]

react-book is set to stock: 0 on purpose. It’s a deliberate case for seeing how the sold-out indicator behaves in the catalog.

It’s fine if you don’t have image files yet. Just create the public/images/products/ folder; if this is your own project, drop in whatever images fit. For learning, you can grab some from a site like Unsplash and save them under matching names. Without images, the next/image setup we build in #2 will show a placeholder.

First pass at the product utilities #

In src/app/lib/products.js, sketch the first version of the functions that read and look up products. The actual screens come in #2, but having the data-access functions ready keeps the flow uninterrupted.

src/app/lib/products.js
import products from '../../../data/products.json';

export function getAllProducts() {
  return products;
}

export function getProductsByCategory(category) {
  if (!category) return products;
  return products.filter((p) => p.category === category);
}

export function getProductBySlug(slug) {
  return products.find((p) => p.slug === slug) ?? null;
}

export function getAllCategories() {
  return Array.from(new Set(products.map((p) => p.category)));
}

What each function does:

  • getAllProducts() — the full product array
  • getProductsByCategory(category) — filter by category; returns everything if no argument
  • getProductBySlug(slug) — used by the product detail page; null if not found
  • getAllCategories() — used for category chips / the sidebar

There’s one difference from the blog’s posts.js. The blog needed fs to read files at runtime, but here we simply import JSON. Next.js converts JSON into a JavaScript module at build time. Using fs would pick up dynamic changes (new files being added), but our product seed doesn’t change that often, so the import is simpler.

First pass at the order store #

Sketch src/app/lib/orders.js as well — it actually gets filled in during #4.

src/app/lib/orders.js
const orders = new Map();

export function saveOrder(order) {
  orders.set(order.id, order);
}

export function getOrder(orderId) {
  return orders.get(orderId) ?? null;
}

A single module-scoped Map. It lives only while the server is up and disappears on restart. Enough for learning, but a real service needs a DB. In the final post I’ll point out where this should move.

Verify the setup #

We haven’t touched any pages at this stage, so spinning up the dev server only shows the default Next.js screen. Still, let’s confirm everything runs.

dev server
npm run dev

If http://localhost:3000 shows the default screen, you’re good. The next post starts building the real screens.

Wrap-up #

This post laid the foundation for the shop build series.

  • Wrote down requirements clearly (list / detail / cart / checkout / order confirmation)
  • Pinned down the tech decisions (App Router / JSON products / Context cart / in-memory orders / payment simulation)
  • Sketched the route tree and component layout
  • Defined the product and order data models
  • Created the project, folder structure, and product seed data
  • Drafted the first version of the products.js / orders.js utilities

Real screen work begins next. In “Build an Online Shop with Next.js #2: Product Catalog,” we’ll use the getAllProducts / getProductBySlug we wrote to build the /products and /products/[slug] pages, then wire up the category filter with searchParams.

X