A headless e-commerce storefront built on Nuxt 4 against the Shopify Storefront API (GraphQL). Built as a portfolio/demo project — the architecture and patterns here are meant to be a realistic starting point for a real Shopify headless build, not a toy.
Live demo: shop-base-nu.vercel.app
- Full commerce flow: catalog with filters/sort, PDP, cart with optimistic UI, discount codes, Shopify-hosted checkout
- Multi-currency (Shopify Markets) and i18n (English/Russian), both driving real Storefront API
@inContextqueries - Customer login and order history via the Customer Account API (OAuth 2.0 + PKCE)
- Product recommendations and recently-viewed, via two of the Storefront API's less commonly used fields
- SEO (sitemap, structured data, hreflang), a CI pipeline, and a Playwright E2E suite that runs against the live store
- Every feature was verified against real Shopify data, not just built and assumed — see individual PRs for the real bugs that surfaced this way and how they were fixed
- Nuxt 4 / Vue 3 (Composition API,
<script setup>), TypeScript strict mode - Pinia — cart state, persisted client-side
- VueUse —
useStoragefor cart persistence, etc. - shadcn-vue + Tailwind CSS v4 — UI kit, vendored into
shared/uiwith a minimal mono theme on top (Inter, a single indigo accent) - Zod — runtime validation of every Storefront API response, form/URL input parsing
- @shopify/storefront-api-client — GraphQL client for the Storefront API
- @graphql-codegen — generates TypeScript types from the live Storefront API schema
- Vitest + Vue Testing Library — unit tests
- ESLint (flat config, via @nuxt/eslint) + Prettier
app/ Nuxt's own directory: app.vue, plugins, middleware, layouts, pages.
This is the FSD "app" layer (init, providers, routing) — Nuxt requires
pages/ to live here for file-based routing, so it's nested rather than
top-level, unlike the other FSD layers below.
server/ Nitro server routes — currently just the Customer Account API's OAuth
flow and the account/orders endpoints it backs. Everything else in this
app talks to Shopify's Storefront API directly (client-side/SSR, no
server route needed); this is the one integration that has to be
server-only, since it holds the session cookie (PKCE code verifier during
login, then the access/refresh tokens) that must never reach client-side JS.
widgets/ Large independent UI blocks: Header, ProductGrid, CartDrawer, Footer.
features/ User scenarios: add-to-cart, product-filter, product-sort, product-search,
checkout-form, customer-auth.
entities/ Business entities (product, cart, order, customer) — each owns its types,
Zod schemas, GraphQL queries/mutations, and API functions. Components
NEVER call the Storefront/Customer Account APIs directly; they always go
through entities/*/api.
shared/ UI kit (shared/ui, vendored shadcn-vue components), the Shopify API
clients (shared/api/shopify, shared/api/shopify-customer), generic
utilities, and common types.
widgets/, features/, entities/, and shared/ live at the project root, as
siblings of app/ — not nested inside it. This is deliberate: Nuxt's srcDir
defaults to app/, which is where Nuxt's own conventions (pages, layouts, plugins)
have to live, but the FSD layers are project-wide concerns and shouldn't be forced
under Nuxt's directory. They're wired in via:
aliasinnuxt.config.ts(@shared,@entities,@features,@widgets) for importscomponentsinnuxt.config.tssoshared/ui/*andwidgets/*auto-import as components@sourcedirectives inapp/assets/css/main.css, because Tailwind v4's automatic content detection only scans Nuxt's Vite root (app/) and would otherwise miss everything outside itpathsintsconfig.json, duplicating thenuxt.config.tsaliases — Nuxt's own generated tsconfig already resolves these correctly, but static tools that readtsconfig.jsondirectly (like the shadcn-vue CLI) need it declared there toodir.sharedinnuxt.config.ts, pointed away from its default ('shared'). Nuxt reserves a top-levelshared/directory for isomorphic app/Nitro code (aliased#shared) and externalizes it in the production server build with no Vue SFC compiler attached to that path. Since our FSDshared/layer holds actual.vuecomponents (shared/ui), leaving Nuxt's default in place makesnuxt buildfail withRollupError: ... Expression expectedthe moment it tries to bundle a component fromshared/ui. Redirectingdir.sharedfrees the name up for FSD.
Within each entity/feature/widget, the public surface is exported from an index.ts
barrel — consumers import from @entities/cart, never from
@entities/cart/model/cart.store directly.
-
Install dependencies:
pnpm install
-
Create a Shopify Storefront API access token (Settings → Apps and sales channels → Develop apps, or a Headless sales channel) and copy
.env.exampleto.env:cp .env.example .env
NUXT_PUBLIC_SHOPIFY_STORE_DOMAIN=your-store.myshopify.com NUXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-storefront-api-token NUXT_PUBLIC_SHOPIFY_API_VERSION=2025-10The Storefront API token is a public/publishable token — it's meant to be exposed client-side, unlike the Admin API. Never put an Admin API token here.
-
(Optional, but recommended) Generate TypeScript types from your store's live schema:
pnpm codegen
-
Run the dev server:
pnpm dev
Without a .env, the app still boots — every page fetch fails gracefully into the
error/retry state built into ProductGrid and the product/search pages, since every
async call is wrapped in useAsyncData with explicit loading/error handling.
Deployed as a standard Nuxt SSR app — no framework-specific glue code needed. On Vercel:
- Import the GitHub repo as a new Vercel project. Vercel detects Nuxt automatically and
sets the right build command/output — no
vercel.jsonor nitro preset override needed. - In the project's Environment Variables settings, add the same three variables from
.env.example(NUXT_PUBLIC_SHOPIFY_STORE_DOMAIN,NUXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN,NUXT_PUBLIC_SHOPIFY_API_VERSION). Customer login/order history needs three more — see "Customer accounts" below. - Deploy.
@nuxt/image'scdn.shopify.comdomain allowlist (nuxt.config.ts→image.domains) already covers Shopify-hosted product images.
Other Nitro-supported targets (Netlify, Cloudflare Pages, a plain Node server via
pnpm build && node .output/server/index.mjs) work the same way — set the same three env
vars, no other platform-specific config required.
Logging in and viewing order history goes through Shopify's Customer Account API —
a separate OAuth 2.0 flow from the Storefront API, with its own credentials. Shopify
requires an HTTPS callback URL (no localhost/http), so this needs a deployed
URL (or a tunnel like ngrok) before you can test it — it won't work against plain
pnpm dev on its own.
Setup, in the Shopify admin:
- Settings → Customer accounts → enable "Customer accounts"
- Under the Customer Account API credentials section of that same page, check the client type — as of writing, Shopify only offers public clients for headless storefronts here (no client secret), so this app authenticates with PKCE (RFC 7636) instead of a confidential-client secret. If you ever see a confidential client type offered instead, the code here would need a small change back to secret-based token exchange.
- Register the callback URL:
<NUXT_PUBLIC_SITE_URL>/api/auth/callback - Copy the client ID
Env vars (see .env.example):
NUXT_PUBLIC_SITE_URL=https://your-deployed-domain.example.com
SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=...
NUXT_SESSION_PASSWORD=... # 32+ random chars, e.g. `openssl rand -base64 32`
How it works: server/api/auth/login generates a PKCE code verifier (stashed in
the session cookie) and its SHA-256 challenge, then redirects to Shopify's
authorization endpoint (discovered from the shop's .well-known/openid-configuration,
not hardcoded) with that challenge and a CSRF state. server/api/auth/callback
validates state, exchanges the code plus the stashed verifier for tokens
(shared/api/shopify-customer/pkce.ts, token.ts), and stores them in a sealed
httpOnly session cookie (server/utils/customer-session.ts, via h3's useSession) —
the tokens never reach client-side JS. entities/customer and entities/order call
the Customer Account API's GraphQL endpoint (also discovered, from
.well-known/customer-account-api) using that session's access token, proxied
through server/api/account/* routes.
Caveat: the OAuth mechanics (discovery, PKCE, token exchange/refresh, session
handling) are verified live end to end — including catching and fixing two real bugs
along the way (Shopify only offering a public/PKCE client here, and an env var naming
mismatch that silently sent an empty client_id). What's still unverified is the
Order/LineItem field selections in entities/order: this dev store has no real
purchase history to log in and view, so that query path hasn't actually run against
live data yet.
e2e/ holds smoke tests that run against a real Nuxt dev server with no
mocking — catalog browsing, filters/sort, PDP + add-to-cart, cart drawer
quantity/remove, the checkout redirect, search, and the SEO routes
(/sitemap.xml, /robots.txt, /ru). They hit the same live Shopify store
as pnpm dev, so they need the same .env as local development (at minimum
NUXT_PUBLIC_SHOPIFY_STORE_DOMAIN, NUXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN,
NUXT_SESSION_PASSWORD — the last one because AuthMenu calls
/api/auth/session on every page load, which needs a session secret to boot
even if you never log in).
Run locally:
pnpm exec playwright install --with-deps chromium # once
pnpm test:e2eplaywright.config.ts boots pnpm dev for you and waits for it to be ready
(webServer), so you don't need a server already running unless you want to
reuse one (reuseExistingServer is on outside of CI).
These are not part of the required ci.yml pipeline — every run adds
real carts/checkouts to the live store, and CI would need the store
credentials as secrets, which not everyone forking this repo will have. They
run instead in a separate .github/workflows/e2e.yml, triggered manually
(workflow_dispatch) or on a weekly schedule, gated behind these repo
secrets: SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_TOKEN,
NUXT_SESSION_PASSWORD.
| Script | What it does |
|---|---|
pnpm dev |
Start the Nuxt dev server |
pnpm build |
Production build |
pnpm generate |
Static/prerendered build |
pnpm typecheck |
vue-tsc --noEmit |
pnpm lint / lint:fix |
ESLint |
pnpm format / format:check |
Prettier |
pnpm test / test:watch / test:coverage |
Vitest |
pnpm test:e2e |
Playwright smoke tests against a real dev server (see "E2E tests" above) |
pnpm codegen |
Regenerate shared/types/storefront.generated.ts from the live Storefront API schema |
- Product catalog with SSR, cursor-based pagination (
useAsyncData+ "load more"), filters (in-stock, product type, tag, price range) and sort — all synced to the URL query string - Product detail page: clickable image gallery, option (color/size) selection, availability-aware add-to-cart
- Cart: Pinia store, cart id persisted to
localStoragevia VueUse, optimistic UI on add (the line appears immediately, then reconciles with the server response — or rolls back on failure) - Discount codes: apply/remove in the cart drawer (
cartDiscountCodesUpdate), with per-code applicability shown (Shopify still returns an invalid code, just markedapplicable: false, rather than erroring).cost.subtotalAmountalready reflects any applied discount at the cart stage (verified live), so the subtotal shown is simply the discounted price — no separate "before/after" total to compute - Checkout: redirects to Shopify's hosted checkout (
cart.checkoutUrl) — this app never touches payment data - Search page (Storefront
searchquery) - Product recommendations ("You may also like") on the PDP, via the Storefront API's
ML-based
productRecommendations(productHandle:, intent: RELATED)— a plain list, not paginated. Purely supplementary: a failed fetch or a product with no recommendations just means the section doesn't render, never an error on the page - Recently viewed products on the PDP — a client-side (
localStorage) list of visited product IDs, batch-fetched in a single request via the Storefront API's genericnodes(ids:)field rather than oneproduct(handle:)call per ID - Multi-currency (Shopify Markets): a currency switcher in the header sets a
@inContext(country:)on every product/search/recommendations query, so prices and currency symbols reflect the selected market end to end. A cart's currency is fixed at creation (@inContextonly needed oncartCreate, verified live — a cart re-queried later with no context at all still reports the currency it was created in), so switching markets doesn't change an existing cart's prices, only new ones. Selection persists in a cookie (entities/market, shared by SSR and CSR) - Customer login (Customer Account API, OAuth 2.0) and order history at
/account— see the "Customer accounts" section above for setup and its current caveat - i18n: English + Russian (
i18n/locales/), locale-prefixed routes - SEO: per-page title/description/Open Graph tags,
ProductJSON-LD structured data on the PDP,hreflangalternates for both locales, a dynamic/sitemap.xmlcovering every product handle, and/robots.txt - Every Storefront API response is validated with Zod at the
shared/api/shopifyboundary before it reaches application code - Loading/error states on every async call (catalog, PDP, search)
- Responsive header: found via mobile-viewport QA that the header (logo, nav, search,
currency/locale switchers, auth, cart) never wrapped or collapsed at any width, so
it silently overflowed the viewport on narrow screens — which broke more than the
visual, since the overflowing content intercepted clicks meant for the page below
it. Fixed with
flex-wrapand a narrower search input below thesmbreakpoint; covered by a permanent E2E test (e2e/mobile-header.spec.ts) at an iPhone 13 viewport - Tests: 60 unit tests across
entities/product,entities/cart,entities/customer, andentities/order(Zod schema parsing/validation, including theedgeListconnection-flattening transform and the cart's optimistic add/rollback, quantity/remove, and discount code apply/remove flows), theadd-to-cart,checkout-form,cart-discount, andrecently-viewedfeature composables, the filter query-string builder, and the shared Storefront API user-error assertion - E2E smoke tests (Playwright) against a real dev server — see "E2E tests" above
- Customer profile editing / address book (read-only profile + order history only)
- The
Order/LineItemfield selections inentities/orderare implemented against Shopify's documented schema but not yet exercised against a real order — this dev store has no purchase history to log in and view. Login itself (PKCE OAuth flow end to end) is verified live.
eslint-flat-config-utils@3.x (a transitive dep of @nuxt/eslint) calls
Object.groupBy, which doesn't exist before Node 21. If you're on Node 20.19.x
(officially still supported by ESLint 10), lint will crash with
Object.groupBy is not a function. package.json pins it back to 2.1.4 via a
pnpm.overrides entry to work around this — remove the override once you're on
Node 22+ or upstream fixes it.
To build the application for production:
corepack enable
pnpm install --frozen-lockfile
pnpm buildThis compiles the server into .output/server/index.mjs and places the static assets in .output/public/.
The Nuxt backend process runs on Node and is managed by PM2:
pm2 start "npx tsx server.ts" --name shopbase-apiThe server listens on process.env.PORT (configured to 3002).
Configure Caddy to serve the static files and reverse-proxy the API requests to the Nuxt process:
http://shopbase.yankvasya.dev {
root * /var/snap/caddy/common/sites/shopbase
file_server
encode gzip
handle /api/* {
reverse_proxy localhost:3002
}
try_files {path} /index.html
}