Set Up a Headless Storefront With Next.js in a Weekend

by Emma Rodriguez
Set Up a Headless Storefront With Next.js in a Weekend

You've probably heard the term "headless commerce" thrown around and quietly wondered if it's just a buzzword for developers with too much free time. I get it — when I was running my first store, anything beyond clicking buttons in a dashboard felt like overkill.

But here's the thing: headless storefronts have quietly become the fastest way for small stores to escape platform fees, own their own performance, and stop paying for features they'll never use. My second store cut its monthly platform costs by 38% after moving the front end off a hosted SaaS and onto a self-managed Next.js app connected to a lightweight commerce API. The checkout conversion rate went up 11% in the first 90 days — mostly because the pages loaded in under 1.2 seconds on mobile.

This tutorial walks you through building a working headless storefront using Next.js over a weekend. No CS degree required. If you can copy-paste a snippet and read an error message, you're ready.

What "Headless" Actually Means for a Small Store

Traditional platforms — think any hosted cart solution — bundle the storefront (what shoppers see) with the backend (products, orders, inventory) into one locked package. Headless splits those two apart. Your backend lives somewhere cheap and API-driven; your frontend is a completely separate app you control.

For you, that means:

  • Your design is truly yours. No theme limitations, no upgrade breaking your layout.
  • Performance is in your hands. Next.js pre-renders pages at build time, so product pages load fast by default.
  • You pay for what you use. A commerce API that handles catalog and checkout can run $0–$29/month at small scale. Your Next.js frontend can host free on Vercel or Netlify up to generous traffic limits.

The tradeoff is honest: you own more of the stack, so you maintain more of it. For stores doing under $50k/year, a weekend setup and a couple of hours of maintenance per quarter is usually worth the savings.

What You'll Need Before You Start

Keep this list short and real:

  1. Node.js 18+ installed locally. Run node -v in your terminal to check.
  2. A commerce backend with a REST or GraphQL API. Good options at the SMB level include Medusa (open-source, self-hostable), Crystallize, or a simple Shopify Storefront API connection if you're already on Shopify and just want a custom front end. This tutorial uses generic REST calls so the concepts apply to any of them.
  3. A free Vercel account for deployment. You can stay on the free tier until you're doing serious traffic.
  4. Basic comfort with the terminal. If you can run npm install, you're fine.

That's genuinely it. You don't need to know React deeply — Next.js does a lot of the heavy lifting.

Day One: Scaffold the Project and Pull Real Products

Open your terminal and run:

npx create-next-app@latest my-store --app --js
cd my-store
npm run dev

You'll see the default Next.js welcome page at http://localhost:3000. Good — that's your blank canvas.

Now create a file at lib/api.js. This is where all your commerce API calls will live, kept separate from your UI components:

const BASE_URL = process.env.COMMERCE_API_URL;

export async function getProducts() {
  const res = await fetch(`${BASE_URL}/products`, {
    headers: { Authorization: `Bearer ${process.env.COMMERCE_API_KEY}` },
    next: { revalidate: 60 } // ISR: re-fetch every 60 seconds
  });
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export async function getProduct(slug) {
  const res = await fetch(`${BASE_URL}/products/${slug}`, {
    headers: { Authorization: `Bearer ${process.env.COMMERCE_API_KEY}` },
    next: { revalidate: 60 }
  });
  if (!res.ok) throw new Error('Failed to fetch product');
  return res.json();
}

Create a .env.local file in your project root:

COMMERCE_API_URL=https://your-backend.example.com/api
COMMERCE_API_KEY=your_secret_key_here

Never commit this file. Add .env.local to your .gitignore right now — that's a mistake I made once and it cost me a panicked afternoon rotating API keys.

Next, build your product listing page. Replace the contents of app/page.js:

import { getProducts } from '@/lib/api';
import Link from 'next/link';

export default async function HomePage() {
  const products = await getProducts();

  return (
    <main style={{ padding: '2rem' }}>
      <h1>Our Products</h1>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1.5rem' }}>
        {products.map((p) => (
          <Link key={p.id} href={`/products/${p.slug}`} style={{ textDecoration: 'none', color: 'inherit' }}>
            <div style={{ border: '1px solid #eee', borderRadius: '8px', padding: '1rem' }}>
              <img src={p.images[0]?.url} alt={p.name} style={{ width: '100%', borderRadius: '4px' }} />
              <h2 style={{ fontSize: '1rem', margin: '0.5rem 0' }}>{p.name}</h2>
              <p>${(p.price / 100).toFixed(2)}</p>
            </div>
          </Link>
        ))}
      </div>
    </main>
  );
}

This is a React Server Component — it fetches data on the server, ships HTML to the browser, and loads fast. No client-side waterfall. If your product list has 24 items, that's 24 product cards rendered before the browser gets involved.

Create the dynamic product page at app/products/[slug]/page.js:

import { getProduct } from '@/lib/api';

export default async function ProductPage({ params }) {
  const product = await getProduct(params.slug);

  return (
    <main style={{ padding: '2rem', maxWidth: '640px', margin: '0 auto' }}>
      <img src={product.images[0]?.url} alt={product.name} style={{ width: '100%', borderRadius: '8px' }} />
      <h1>{product.name}</h1>
      <p style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>${(product.price / 100).toFixed(2)}</p>
      <p>{product.description}</p>
      <button style={{ padding: '0.75rem 1.5rem', background: '#000', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
        Add to Cart
      </button>
    </main>
  );
}

At this point you have a real, data-driven storefront. The "Add to Cart" button doesn't do anything yet — that's Day Two.

Day Two: Wire Up Cart State and Checkout

Cart state is where most first-timers get tangled. The cleanest approach at this scale: store the cart in localStorage on the client, then send a cart creation request to your commerce backend when the shopper hits checkout.

Create lib/cart.js:

export function getCart() {
  if (typeof window === 'undefined') return [];
  return JSON.parse(localStorage.getItem('cart') || '[]');
}

export function addToCart(product) {
  const cart = getCart();
  const existing = cart.find((i) => i.id === product.id);
  if (existing) {
    existing.quantity += 1;
  } else {
    cart.push({ ...product, quantity: 1 });
  }
  localStorage.setItem('cart', JSON.stringify(cart));
}

export function clearCart() {
  localStorage.removeItem('cart');
}

Now make the "Add to Cart" button actually work. Since it needs browser APIs (localStorage), it has to be a Client Component. Create components/AddToCartButton.js:

'use client';
import { addToCart } from '@/lib/cart';

export default function AddToCartButton({ product }) {
  return (
    <button
      onClick={() => {
        addToCart(product);
        alert(`${product.name} added to cart!`);
      }}
      style={{ padding: '0.75rem 1.5rem', background: '#000', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}
    >
      Add to Cart
    </button>
  );
}

Import and use it in your product page. The Server Component handles data fetching; the Client Component handles the click. That's the headless pattern in miniature — clean separation, each layer doing one job.

For checkout, most commerce backends expose a /checkout endpoint that accepts a cart array and returns a hosted payment URL (Stripe, PayPal, etc.). Create a simple API route at app/api/checkout/route.js:

import { NextResponse } from 'next/server';

export async function POST(request) {
  const { items } = await request.json();
  const res = await fetch(`${process.env.COMMERCE_API_URL}/checkout`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.COMMERCE_API_KEY}`
    },
    body: JSON.stringify({ items })
  });
  const data = await res.json();
  return NextResponse.json({ checkoutUrl: data.checkout_url });
}

On the client, after the shopper clicks "Checkout", you call this route, get back a URL, and redirect them. The payment page is hosted by your backend or payment processor — you never touch card data. That's both simpler and safer.

Deploy in About 15 Minutes

Push your project to a GitHub repo (make sure .env.local is in .gitignore). Then:

  1. Log into vercel.com and click "Add New Project."
  2. Import your GitHub repo.
  3. Under "Environment Variables," add COMMERCE_API_URL and COMMERCE_API_KEY with your real values.
  4. Click Deploy.

Vercel builds your Next.js app, sets up a global CDN, and gives you an HTTPS URL in about 3 minutes. Free tier covers 100GB bandwidth/month — more than enough for a store doing under a few thousand visitors a day.

Your Lighthouse performance score on a clean Next.js app with server-rendered pages typically lands between 90 and 98 out of 100 without any extra optimization. That matters: Google's Core Web Vitals directly influence search ranking, and a 1-second improvement in mobile load time correlates with roughly a 7% increase in conversions according to widely-cited industry benchmarks.

A Quick Real-World Example

A candle store owner I know — 12 SKUs, no developer on staff — followed basically this pattern using Medusa as the backend and Next.js on the front. Total monthly infrastructure cost: $14 (a $5 Medusa server on a VPS + a $9 domain renewal amortized monthly). She was previously paying $79/month on a hosted platform plus transaction fees.

Her first month post-migration, she spent one Saturday on setup and about two hours the following week fixing a mobile layout issue. Since then, she touches the codebase maybe once a quarter. Her store loads in 0.9 seconds on a mid-range Android phone. She's not a developer — she just followed the steps, Googled the error messages, and kept going.

Could you do the same? Honestly, yes — especially with the scaffolding above already written out for you.

Three Things You Can Do Today

  1. Run npx create-next-app@latest right now and get the dev server running. Even five minutes of hands-on time makes the rest of this feel real.
  2. Pick your commerce backend this weekend. If you're already on Shopify, the Storefront API is a zero-migration starting point. If you're starting fresh, Medusa's free tier is hard to beat.
  3. Set up your Vercel account before you need it. It takes four minutes and costs nothing. Having it ready removes one friction point when you're ready to deploy.

Headless commerce isn't a silver bullet, and it's not for every store. But if you're paying more than $50/month in platform fees, loading slowly on mobile, or feeling boxed in by your theme — a weekend is genuinely enough time to have something real running. You don't need to be a developer. You need to be someone who follows steps and doesn't quit when the first error message shows up.

You've already read this far. That's the harder part.