VueConf US 2025 — Epicmax is speaking!Register now →
Epicmax
June 3, 2026· by Epicmax Team

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.

Nuxt 4MedusaJSE-commerceVue.js
How to Build an E-Commerce Store with MedusaJS V2 and Nuxt 4

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 /app out 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 inside app/ rather than the project root. Configuration files (nuxt.config.ts, content.config.ts, package.json) stay at the root.
  • Smarter data fetchinguseAsyncData and useFetch share 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

ConcernMedusaJS V2Nuxt 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 /appNot 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):

  1. 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.
  2. 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).
  3. Manage inventory — if you have the inventory module enabled, set stock levels per variant and per location.
  4. Publish — toggle the product status from "Draft" to "Published" to make it visible via the Storefront API.

Orders

When a customer completes checkout:

  1. The order appears under Orders in the admin.
  2. You can fulfill items (mark as shipped, add tracking numbers).
  3. You can issue refunds or create returns directly from the order detail page.
  4. 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_CORS as environment variables.
  • Deploy the Node.js server to Railway, Render, Fly.io, or a VPS.

Nuxt 4 storefront:

  • Set VITE_MEDUSA_PUBLISHABLE_KEY and NUXT_PUBLIC_MEDUSA_URL pointing to your production Medusa URL.
  • Run npm run generate for a fully static build or npm run build for 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 😍