How to Build an E-Commerce Store with MedusaJS V2 and Nuxt 4
Step-by-step guide to building a headless e-commerce store with MedusaJS V2 as the backend and Nuxt 4 as the storefront. Covers setup, API integration, and product management.

Headless commerce has become the default architecture for teams that want full control over the storefront without reinventing the backend. MedusaJS V2 and Nuxt 4 are a natural fit: Medusa handles products, carts, orders, and payments while Nuxt 4 gives you a fast, SEO-friendly Vue-based storefront. In this guide you will set up both, wire them together, and understand how to manage your store going forward.
What is MedusaJS V2
Medusa (now at version 2.x) is an open-source, headless commerce engine built on Node.js and TypeScript. The original v1 was a monolithic Express server. V2 was redesigned from the ground up as a modular platform — every commerce domain (products, inventory, carts, orders, customers, promotions, payments) is an independent module that you can swap or extend without touching the rest of the system.
Key characteristics of Medusa V2:
- Modular architecture — install only the modules your store needs. A digital-goods store can skip the inventory module entirely; a B2B store can layer in company accounts.
- TypeScript-first — the entire codebase and its public APIs are fully typed.
- Built-in admin dashboard — ships at
/appout of the box. No separate install required. - Storefront API — a dedicated HTTP API surface designed for customer-facing operations: browse products, manage carts, place orders.
- Workflow engine — long-running operations (order fulfillment, return processing) are modeled as resumable workflows rather than synchronous request handlers.
- PostgreSQL as the default database — reliable, ACID-compliant, well-supported by hosting providers.
- Plugin ecosystem — official integrations for Stripe, PayPal, Braintree, SendGrid, S3, and more.
Medusa V2 runs as a standalone HTTP server. Your storefront is a completely separate application that talks to it over HTTP — which is exactly where Nuxt 4 comes in.
What is Nuxt 4
Nuxt 4 is the latest major version of the Nuxt framework, built on top of Vue 3. It became the stable release in July 2025 and introduced several developer-experience improvements over Nuxt 3 while remaining backwards-compatible for most projects.
What changed in Nuxt 4 that matters for a commerce storefront:
app/directory — all application code (pages/,components/,composables/,layouts/) now lives insideapp/rather than the project root. Configuration files (nuxt.config.ts,content.config.ts,package.json) stay at the root.- Smarter data fetching —
useAsyncDataanduseFetchshare state via a singleton cache, clean up on component unmount, and support reactive keys for automatic refetching. This is important for dynamic routes like/products/[handle]. - Better TypeScript — Nuxt now generates separate TS configs for the app, server, and shared contexts, which reduces cross-environment type leakage.
- Faster cold starts — Node.js compile cache, native file watching, and socket-based communication between the CLI and Vite.
For a commerce storefront you get SSR + SSG out of the box, which means product pages can be pre-rendered at build time for SEO and then hydrated on the client.
Why This Combination is Great
| Concern | MedusaJS V2 | Nuxt 4 |
|---|---|---|
| Product catalog | ✅ Managed via admin or API | ✅ Fetched and cached at build time |
| Cart & checkout | ✅ Stateful cart sessions, payment integrations | ✅ Client-side composables with SSR fallback |
| SEO | ❌ Not applicable (headless backend) | ✅ SSR/SSG, useSeoMeta, sitemap |
| Admin UI | ✅ Ships at /app | Not needed |
| Extensibility | ✅ Custom modules, workflows, API routes | ✅ Nuxt plugins, server routes, middleware |
| TypeScript | ✅ Full coverage | ✅ Full coverage |
The separation of concerns is clean: Medusa owns the commerce data and business logic; Nuxt owns the presentation layer. You can redesign the storefront completely without touching the backend, and you can upgrade Medusa modules without redeploying the frontend.
Both tools are open-source, actively maintained, and free to self-host — which keeps infrastructure costs predictable.
Prerequisites
Before you start, make sure you have the following installed:
- Node.js 20+ (check with
node -v) - PostgreSQL 15+ running locally or a managed database URL
- Git
You will run two separate servers during development:
- Medusa backend on
http://localhost:9000 - Nuxt 4 storefront on
http://localhost:3000
Setting Up the MedusaJS V2 Backend
1. Create a new Medusa project
npx create-medusa-app@latest my-store-backend
cd my-store-backend
The CLI will ask for a project name and a PostgreSQL connection string. Provide a local database URL in the format:
postgres://postgres:password@localhost:5432/medusa_store
The scaffold installs dependencies, runs database migrations, and seeds demo data (a sample product catalog so you have something to work with right away).
2. Start the Medusa server
npm run dev
Medusa starts on http://localhost:9000. Open http://localhost:9000/app to access the admin dashboard. Create an admin account when prompted on first launch.
3. Enable CORS for your Nuxt storefront
Open medusa-config.ts (or medusa-config.js) in the project root and add your Nuxt dev URL to the storefront CORS list:
// medusa-config.ts
import { defineConfig } from "@medusajs/framework/config"
export default defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
http: {
storeCors: process.env.STORE_CORS ?? "http://localhost:3000",
adminCors: process.env.ADMIN_CORS ?? "http://localhost:9000",
authCors: process.env.AUTH_CORS ?? "http://localhost:3000",
jwtSecret: process.env.JWT_SECRET ?? "supersecret",
cookieSecret: process.env.COOKIE_SECRET ?? "supersecret",
},
},
})
Restart the server after making config changes.
4. Create an API key for the storefront
In the admin dashboard, go to Settings → API Keys and create a new Publishable API Key. Copy the key — you will need it in the Nuxt project.
Setting Up the Nuxt 4 Storefront
1. Create a Nuxt 4 project
npx nuxi@latest init my-store-frontend
cd my-store-frontend
Accept the defaults. The scaffold uses Nuxt 4 by default with the app/ directory structure.
2. Install the Medusa JS SDK
npm install @medusajs/js-sdk
The @medusajs/js-sdk package is the official TypeScript client for the Medusa Storefront API. It handles authentication headers, cart session management, and response typing automatically.
3. Configure the Medusa client
Create a composable that exports a shared Medusa SDK instance:
// app/composables/useMedusa.ts
import Medusa from "@medusajs/js-sdk"
const medusa = new Medusa({
baseUrl: "http://localhost:9000",
debug: process.env.NODE_ENV === "development",
publishableKey: import.meta.env.VITE_MEDUSA_PUBLISHABLE_KEY,
})
export function useMedusa() {
return medusa
}
Add your publishable key to .env:
VITE_MEDUSA_PUBLISHABLE_KEY=pk_...your_key_here...
4. Set the region
Medusa V2 requires all store requests to target a specific region. Fetch the available regions and store the active region ID in a composable:
// app/composables/useRegion.ts
export const useRegion = () => {
return useState<string | null>("regionId", () => null)
}
Then resolve the region once in app.vue or a plugin:
// app/plugins/region.ts
export default defineNuxtPlugin(async () => {
const medusa = useMedusa()
const regionId = useRegion()
if (!regionId.value) {
const { regions } = await medusa.store.region.list()
regionId.value = regions[0]?.id ?? null
}
})
Fetching Products and Building Pages
Product listing page
<!-- app/pages/products/index.vue -->
<script setup lang="ts">
const medusa = useMedusa()
const regionId = useRegion()
const { data } = await useAsyncData("products", () =>
medusa.store.product.list({
region_id: regionId.value ?? undefined,
limit: 20,
})
)
const products = computed(() => data.value?.products ?? [])
useSeoMeta({
title: "All Products",
description: "Browse our full product catalog.",
})
</script>
<template>
<div class="container mx-auto px-4 py-12">
<h1 class="text-4xl font-bold mb-8">Products</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<NuxtLink
v-for="product in products"
:key="product.id"
:to="`/products/${product.handle}`"
class="group"
>
<NuxtImg
v-if="product.thumbnail"
:src="product.thumbnail"
:alt="product.title"
class="w-full aspect-square object-cover rounded-lg mb-3"
/>
<p class="font-semibold group-hover:underline">{{ product.title }}</p>
</NuxtLink>
</div>
</div>
</template>
Product detail page
<!-- app/pages/products/[handle].vue -->
<script setup lang="ts">
const route = useRoute()
const medusa = useMedusa()
const regionId = useRegion()
const { data } = await useAsyncData(`product-${route.params.handle}`, () =>
medusa.store.product.list({
handle: route.params.handle as string,
region_id: regionId.value ?? undefined,
})
)
const product = computed(() => data.value?.products[0])
if (!product.value) {
throw createError({ statusCode: 404, message: "Product not found" })
}
const selectedVariantId = ref(product.value?.variants?.[0]?.id ?? null)
useSeoMeta({
title: product.value?.title,
description: product.value?.description ?? "",
ogImage: product.value?.thumbnail ?? undefined,
})
</script>
<template>
<div v-if="product" class="container mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-2 gap-12">
<NuxtImg
v-if="product.thumbnail"
:src="product.thumbnail"
:alt="product.title"
class="w-full rounded-lg"
/>
<div>
<h1 class="text-3xl font-bold mb-4">{{ product.title }}</h1>
<p class="text-gray-600 mb-6">{{ product.description }}</p>
<button
class="bg-black text-white px-8 py-3 rounded-lg hover:bg-gray-800 transition-colors"
@click="addToCart"
>
Add to cart
</button>
</div>
</div>
</div>
</template>
Managing the Cart
Medusa V2 uses a cart session identified by a cart ID that you store client-side (in a cookie or localStorage). The SDK makes this straightforward.
// app/composables/useCart.ts
export const useCartId = () => useState<string | null>("cartId", () => null)
export function useCart() {
const medusa = useMedusa()
const regionId = useRegion()
const cartId = useCartId()
async function getOrCreateCart() {
if (cartId.value) return cartId.value
const { cart } = await medusa.store.cart.create({
region_id: regionId.value ?? undefined,
})
cartId.value = cart.id
return cart.id
}
async function addItem(variantId: string, quantity = 1) {
const id = await getOrCreateCart()
await medusa.store.cart.createLineItem(id, {
variant_id: variantId,
quantity,
})
}
return { getOrCreateCart, addItem }
}
Use addItem from any product page to add variants to the cart.
Managing Products and Orders in the Admin
Products
From the Medusa admin dashboard (http://localhost:9000/app):
- Create a product — go to Products → New product. Fill in the title, description, and add variants (e.g., size S/M/L, color). Each variant gets a SKU, price per region, and optional inventory tracking.
- Upload images — drag images directly in the product editor. Medusa stores them in your configured file storage (local filesystem in development, S3/GCS in production).
- Manage inventory — if you have the inventory module enabled, set stock levels per variant and per location.
- Publish — toggle the product status from "Draft" to "Published" to make it visible via the Storefront API.
Orders
When a customer completes checkout:
- The order appears under Orders in the admin.
- You can fulfill items (mark as shipped, add tracking numbers).
- You can issue refunds or create returns directly from the order detail page.
- The workflow engine handles multi-step operations (e.g., partial fulfillments, split shipments) without custom code.
Regions and Currencies
Under Settings → Regions, configure which countries belong to each region and which currency and payment providers apply. This is important if you sell internationally — product prices are set per region, so you can localize pricing without duplicating products.
Deploying to Production
When you are ready to deploy:
Medusa backend:
- Use a managed PostgreSQL service (e.g., Supabase, Neon, Railway, AWS RDS).
- Set
DATABASE_URL,JWT_SECRET,COOKIE_SECRET,STORE_CORS,ADMIN_CORSas environment variables. - Deploy the Node.js server to Railway, Render, Fly.io, or a VPS.
Nuxt 4 storefront:
- Set
VITE_MEDUSA_PUBLISHABLE_KEYandNUXT_PUBLIC_MEDUSA_URLpointing to your production Medusa URL. - Run
npm run generatefor a fully static build ornpm run buildfor SSR. - Deploy to Netlify, Vercel, or Cloudflare Pages.
Update STORE_CORS in your Medusa config to include the production Nuxt domain.
Conclusion
MedusaJS V2 and Nuxt 4 give you a modern, fully typed, open-source e-commerce stack where each layer does exactly one job. Medusa owns the commerce logic — catalog, cart, checkout, order management — and exposes it through a clean Storefront API. Nuxt 4 owns the presentation layer, bringing SSR, SEO, and a great Vue developer experience. The two communicate over HTTP, which means you can scale, replace, or upgrade each side independently as your store grows.
If you want help architecting or building your storefront, the Epicmax team has deep experience with Vue 3 and Nuxt projects of all sizes.
Vue.js is our passion. Open Source is our culture 😍