DOCUMENTATION

Build with Nuvi

Quickstarts, concepts, integration guides, migration playbooks, and SDK examples — everything you need to ship a Nuvi-powered store end-to-end.

QUICKSTART

Five-minute quickstart

From sign-up to live store, end to end.

Nuvi is set up so you can have a polished, production-ready store up and running in minutes (domain/DNS may take longer). The five steps:

  1. Sign up at usenuvi.com/register — email + password, no card.
  2. Pick a theme from the gallery. Each ships with sample products and 8–12 sections pre-configured. You can switch themes any time without losing content.
  3. Connect a domain (optional but recommended). Search for one on the dashboard or paste an existing domain — DNS can be auto-configured; SSL typically provisions within minutes.
  4. Connect Stripe or Iyzico for payments. One-click OAuth, or open a fresh merchant account through Nuvi while you wait.
  5. Edit your hero in the Theme Editor — drag a new section in, change the headline, adjust colors. Hit Publish.

That's it. You now have a fully functional store with checkout, customer accounts, blog, and the AI assistant.

TIP
The 22 modules are all included from day one — on every plan, from Starter to Enterprise. You don't pick a plan to "unlock" the blog or the form builder — they're sitting in your dashboard waiting. Every plan also charges 0% transaction fees, and all 24 AI skills work the same; plans differ only in number of sites and performance.
QUICKSTART

Core concepts

How modules, themes, sections, and pages fit together.

Nuvi is built from four conceptual layers. Understanding how they relate makes everything easier:

Modules

The platform's capabilities: blog, page, quote, search, theme, currency, etc. There are 22 of them, all included on every plan. A module is the data + the API + the admin UI for one capability. You don't install or configure modules — they're always on.

Themes

The look of your store. A theme bundles design tokens (colors, fonts, spacing), section definitions, and default settings. Switching theme changes appearance without losing content.

Sections

Composable visual blocks: hero, feature grid, pricing, testimonials, FAQ, CTA. Each section is a React component that reads its content from JSON settings. You drag them in via the editor and reorder.

Pages

Routes on your storefront. The homepage, product detail, blog post, and any custom page (about, careers, etc.) are pages. A page is a list of sections, plus content data (title, slug, SEO meta). Pages are stored in the page module.

NOTE
The same section component renders on the homepage, on a custom landing page, or inside a blog post. Once you've configured a hero you like, you can drop it anywhere.
CONCEPTS

How modules work

Architecture, dependencies, lifecycle.

Each module is a self-contained NPM package — @nuvi/module-blog, @nuvi/module-quote, etc. They live in packages/modules/ and ship with their own data models, service class, manifest, and admin routes.

Modules share standards but are independent: enabling or disabling one (via the module-setting module) doesn't break others. Most modules have no hard dependencies; the few that do declare them in their manifest.json.

Lifecycle

When the platform boots, the module loader reads the ENABLED_MODULES environment variable, registers each module's MikroORM entities, mounts admin and storefront API routes, and exposes the service class for cross-module calls.

Cross-module calls

A module can call another module's service via the Medusa container, e.g. the quote module reads page module data to render templates. There's no HTTP hop — services are JavaScript classes resolved by the DI container.

TIP
Read the modules index for a one-page summary of each module, or /modules/<slug> for a deeper marketing page.
CONCEPTS

Authentication

API keys, customer JWTs, admin tokens.

Three identity contexts you'll work with:

Publishable API key

Sent on every storefront request as x-publishable-api-key. Identifies which store / region the call belongs to; safe to expose in client-side code. Provisioned automatically for every site — find it in your Dashboard.

Customer JWT

Issued by POST /auth/customer/emailpass. Identifies the logged-in shopper. Required for endpoints under /store/customers/me, /store/orders/me, /store/subscriptions. Send as Authorization: Bearer ....

Admin token

For server-side admin tooling and integrations. Never expose to client code. Use only from your own backend or CLI.

Authenticated storefront call
curl https://usenuvi.com/api/store/customers/me \
  -H "x-publishable-api-key: pk_live_..." \
  -H "Authorization: Bearer <customer-jwt>"
WARNING
Never hard-code an admin token in your frontend. If you need server-only operations (price overrides, bulk updates), proxy through your own backend.
MODULES

Building a custom module

Package layout, manifest, MikroORM model, MedusaService.

Modules are independent NPM packages under packages/modules/<name>/. The cleanest reference is @nuvi/module-blog — copy its shape and rename. Five files cover the minimum.

1. Package layout

packages/modules/loyalty/
packages/modules/loyalty/
├── manifest.json              # metadata, deps, routes
├── package.json               # "main": "./src/index.ts"
├── tsconfig.json
└── src/
    ├── index.ts               # re-exports module + service + models
    └── module/
        ├── index.ts           # Module() registration
        ├── service.ts         # MedusaService class
        └── models/
            └── loyalty-account.ts

2. manifest.json

Read by @nuvi/module-sdk's validateManifest(). Fields: name, version, displayName, description, icon, category, isCore, dependencies (other module package names), and a medusa block declaring modules, adminRoutes, apiRoutes.

3. The MikroORM model

Use Medusa's model.define() DSL. Always include store_id for multi-tenant isolation, and index on it:

import { model } from "@medusajs/framework/utils"

const LoyaltyAccount = model.define("loyalty_account", {
  id: model.id().primaryKey(),
  store_id: model.text().default("default").searchable(),
  customer_id: model.text(),
  points: model.number().default(0),
}).indexes([{ on: ["store_id", "customer_id"], unique: true }])

export default LoyaltyAccount

4. The service class

service.ts extends MedusaService({...}) with each model — you get CRUD methods (listLoyaltyAccounts, createLoyaltyAccounts, …) for free. module/index.ts wraps it with Module("loyalty", { service }) and exports the constant LOYALTY_MODULE = "loyalty".

5. Wire it up

  1. Add to bootstrap/medusa/package.json: "@nuvi/module-loyalty": "file:../../packages/modules/loyalty".
  2. Register in bootstrap/medusa/src/lib/module-registry.ts with resolve, key, frontendId, apiRoutes, adminRoutes, isCore.
  3. Add a typed interface to bootstrap/medusa/src/types/module-services.ts for cross-module calls.
  4. Rebuild: cd bootstrap && docker compose up -d --build medusa.
TIP
For shared types — ModuleManifest, ModuleCategory, ThemeBlockManifest — import from @nuvi/module-sdk. The SDK also exports resolveDependencies() for ordering modules and isValidManifest() as a Zod-like guard.
GUIDES

Add your first product

From product creation to live PDP.

The fastest way: Dashboard → Products → New product. Fields you'll fill:

  • Title + handle. The handle becomes the URL slug (/products/your-handle).
  • Description. Rich-text editor with image embeds. Renders on the PDP as-is.
  • Variants. If your product has options (size, color), add them as variant rows. Each variant has its own price and SKU.
  • Images. Drag-drop upload. The first image becomes the cover; reorder by drag.
  • SEO. Optional meta title + description. Falls back to product title + first 160 chars of description.

Hit Publish. The product is live at https://yourstore.com/products/your-handle within seconds to a few minutes — Next.js statically regenerates the page.

TIP
Use the Import module to bulk-add products from a CSV, or pull from your existing Shopify / WooCommerce store. Field mapping is auto-detected for common columns.
GUIDES

Connect a custom domain

Move from yourstore.nuvi.app to yourbrand.com.

Two paths:

Buy through Nuvi

Search a domain in Dashboard → Domains. Pick from 500+ TLDs. Pay $15–30/yr (see pricing). DNS can be auto-configured once payment clears; SSL typically provisions within minutes.

Bring your own

Already own a domain elsewhere? Add it on the same screen. We'll show you the exact CNAME / A records to add at your registrar. SSL provisions automatically once propagation completes (usually 10–30 minutes).

NOTE
Both www.yourbrand.com and yourbrand.com work out of the box — apex (no-www) is canonical, www redirects to it with a 301.

If you're moving from another platform, the Redirect module pre-generates 301s for your old URLs so SEO juice flows to the new ones.

GUIDES

Set up your email

Mailboxes on your own domain — webmail + any mail app.

Professional email on your own domain ([email protected]) — 15 GB storage, full IMAP/SMTP/POP3, webmail, calendar & contacts.

1. Create a mailbox

In Dashboard → Settings → Email, add your domain and create a mailbox. Mailboxes are a $5/mo add-on (see service). The DNS records (MX, SPF, DKIM, DMARC) are added automatically for Nuvi-managed domains; for an external domain we show the exact records to paste at your registrar.

2. Webmail

Open mail.usenuvi.com and sign in with your full email address and password. Webmail includes mail, calendar, contacts, filters and spam controls.

3. Connect your mail app

Prefer Apple Mail, Outlook, Gmail or your phone's built-in mail app? Use these settings:

Incoming — IMAP
Server:   mail.op-email.eu
Port:     993
Security: SSL/TLS
Username: [email protected]
Password: your mailbox password
Outgoing — SMTP
Server:   mail.op-email.eu
Port:     587
Security: STARTTLS
Username: [email protected]
Password: your mailbox password

iPhone / iPad: Settings → Mail → Accounts → Add Account → Other → Add Mail Account, then enter the values above. Apple Mail / Outlook / Gmail app / Android: choose "Other" / "IMAP" and use the same settings. POP3 is also available on port 995 (SSL).

TIP
Most people just use their existing mail app — once connected, your mailbox lives in your usual inbox alongside your other accounts, fully in your brand.

4. Password, 2FA & aliases

Change your password, turn on two-factor authentication, or create per-app passwords from the mailbox panel. Aliases (forwarding addresses like sales@ → your inbox) are free — add them under Settings → Email → Forwarding.

NOTE
Mail not arriving? Allow up to an hour for DNS to propagate after setup, and confirm the MX/SPF/DKIM records are present (listed in Settings → Email).
GUIDES

Customize your theme

Tokens, sections, layouts — without code.

Open the Theme Editor from your dashboard. Three layers of customization, from broad to specific:

1. Tokens (whole-site)

Change accent color, font family, border radius. Every section that uses var(--color-primary) updates instantly. Click a swatch in the inspector or paste a hex.

2. Sections (per-page)

Each page is a stack of sections. Drag a new one in from the left rail; reorder by drag; duplicate or hide via the row menu. Sections have their own settings (heading, image, alignment, etc.).

3. Section settings (per-section)

Click a section to open the inspector. Edit headline copy, swap images, change padding. Live preview updates as you type. Changes are auto-saved as drafts; click Publish to ship.

TIP
Use the AI assistant button in the editor — describe a change in plain language ("make the hero bolder", "add a press logos strip") and it proposes section + token edits you can approve.
GUIDES

Consuming events with webhooks

Built-in workflows today, signed outbound webhooks tomorrow.

WARNING
Outbound webhooks (Nuvi POSTing to your URL) are not yet shipped — they're on the near-term roadmap. The path described in API reference / Webhooks shows the planned event shape. Today, the way to react to events in real time is the workflow engine.

Today: built-in workflows

Every Nuvi tenant ships with an embedded workflow engine, exposed under Dashboard → Workflows (admin route at bootstrap/medusa/src/admin/routes/workflows/). Pick a Nuvi trigger — order placed, payment captured, customer created, quote submitted — and drop in action blocks (HTTP, Slack, Sheets, custom code).

Under the hood, the workflow engine subscribes to internal Medusa events through the same bus that will eventually drive outbound webhooks, so workflows you build today keep working when proper webhooks land.

Tomorrow: signed outbound webhooks

The planned event shape:

  • order.placed — cart converts to an order.
  • order.payment_captured — payment captured.
  • order.fulfilment_shipped — fulfilment ships.
  • customer.created, subscription.renewed, quote.submitted.

Each delivery will carry an x-nuvi-signature header — HMAC-SHA256 of the raw body using your endpoint's signing secret. Verification on a Node.js receiver:

Verify x-nuvi-signature (planned)
import crypto from 'node:crypto'

export function verifyNuviSignature(rawBody: Buffer, header: string, secret: string) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  // Use timingSafeEqual to avoid leaking via response time
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(header, 'hex')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
NOTE
Receivers must read the raw request body before any JSON parser touches it — re-serializing the parsed object will break HMAC. In Express that's express.raw({ type: 'application/json' }); in Next.js route handlers, await req.text() on the request before JSON.parse.
GUIDES

Custom payment provider

Write a Medusa v2 payment provider, plug it into checkout.

Nuvi inherits Medusa v2's payment provider plugin model. The repo ships two examples — Stripe (the official @medusajs/payment-stripe) and iyzico (in-tree at bootstrap/medusa/src/modules/iyzico-payment/). Copy iyzico when adding a new provider.

1. Module shape

A provider is a Medusa module provider, registered against the payment module:

src/modules/myprov-payment/index.ts
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import MyProvService from "./service"

export default ModuleProvider(Modules.PAYMENT, {
  services: [MyProvService],
})

2. Lifecycle methods

Extend AbstractPaymentProvider<Options> with a static identifier and implement the methods Medusa calls during checkout:

  • initiatePayment — open a session with your gateway, return a token / redirect URL.
  • authorizePayment — verify a returned token (post-redirect or post-3DS).
  • capturePayment — finalize an authorized charge.
  • refundPayment — full or partial refund.
  • retrievePayment / getPaymentStatus — read current state.
  • cancelPayment, deletePayment, updatePayment — housekeeping for unfinished sessions.

3. The 3DS / redirect callback

Hosted-page providers (iyzico, PayTR, Mollie) need a public callback to verify the token after redirect. The pattern lives in bootstrap/medusa/src/api/store/iyzico-callback/route.ts: validate the token format, check it belongs to the expected cart, then call your provider's retrieve API.

WARNING
Always re-verify the amount and basket ID on the server in your callback. status === "SUCCESS" on the gateway response is not enough — confirm the captured amount matches the cart total and the basket ID matches the resource being confirmed. Anything less is a price-manipulation hole.

4. Register in medusa-config

Add a conditional entry to the @medusajs/medusa/payment providers array in bootstrap/medusa/medusa-config.ts, gated on whichever env var indicates the provider is configured. iyzico is a working template:

...(process.env.MYPROV_API_KEY ? [{
  resolve: "./src/modules/myprov-payment",
  id: "myprov",
  options: {
    apiKey: process.env.MYPROV_API_KEY,
    secretKey: process.env.MYPROV_SECRET_KEY || "",
  },
}] : []),

5. Test

For Stripe, use the Stripe CLI: stripe listen --forward-to localhost:9000/hooks/payment/stripe. For iyzico, point IYZICO_URI at https://sandbox-api.iyzipay.com. Drive the full flow from the storefront cart page to confirm authorize → capture → fulfilment all run.

MIGRATION

Migrate from Shopify

Products, customers, orders, content, redirects.

The Import module reads a Shopify export and pulls everything in. Steps:

  1. Export from Shopify: Settings → Account → Export store data (or use the Storefront API to pull products + the Admin API for orders).
  2. In Nuvi: Dashboard → Import → New import → Shopify. Upload the file or paste API credentials for direct pull.
  3. Field mapping is auto-detected. Review the preview, fix any conflicts (e.g. duplicate handles).
  4. Hit Run import. Products, variants, customers, and orders import. Media re-uploads to Nuvi storage.
  5. The Redirect module auto-generates 301s from your old Shopify URLs (/products/foo → same path on Nuvi) so SEO is preserved.
WARNING
Before pointing your domain at Nuvi, do a soft launch on a test subdomain. Verify product pages render, checkout completes end-to-end, and the redirect map covers your top 50 trafficked URLs.

Need a hand? Premium Support includes hands-on migration help.

MIGRATION

Migrate from WooCommerce

WP export → Nuvi import.

WooCommerce migration takes the same shape as Shopify but with one extra consideration: WordPress hosts both the storefront and your blog content, so you'll import both.

  1. From WordPress admin, install the Customer / Order / Coupon export plugin and run a full export.
  2. Use the WP REST API to pull blog posts: /wp-json/wp/v2/posts.
  3. In Nuvi: Dashboard → Import → New import → WooCommerce. Upload both files.
  4. Run import. Products + orders go to commerce; posts go to the Blog module with author + category preserved.
  5. Redirects are auto-generated for both /product/slug and /blog/slug patterns.
NOTE
Custom plugin features (Yoast meta, Elementor pages, Advanced Custom Fields) aren't auto-translated. The Import module preserves the data; you'll re-author Yoast meta as Nuvi SEO settings, and Elementor pages should be rebuilt with Nuvi sections.
DEPLOYMENT

Environment variables & secrets

What to set, where it lives, how not to leak it.

Bootstrap reads its config from a single .env file at bootstrap/.env — copied from the canonical bootstrap/.env.example. docker-compose.yml picks values up automatically and forwards them into each service.

Core (always required)

  • POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB_MEDUSA — database connection.
  • MEDUSA_JWT_SECRET, MEDUSA_COOKIE_SECRET — auth secrets. Required in every environment, including dev.
  • STORE_CORS, ADMIN_CORS, AUTH_CORS — comma-separated origins allowed by Medusa.
  • REDIS_PASSWORD — cache + queue.
  • NUVI_INTERNAL_TOKEN + INTERNAL_API_KEY — must match across services.

Object storage (Hetzner)

S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_FILE_URL. The bucket holds product media + theme assets; S3_FILE_URL is the public URL prefix Medusa emits in image responses.

Modules

ENABLED_MODULES is a comma-separated list of module package names that should boot. If unset, every module loads. Core modules (theme, page, module-setting) always load even if you omit them.

ENABLED_MODULES sample
ENABLED_MODULES=@nuvi/module-blog,@nuvi/module-quote,@nuvi/module-media,@nuvi/module-form

Production vs development

Same variables, different sources. In dev, bootstrap/.env is plain text — fine for local. In production:

  • deploy.sh on the server keeps a managed .env outside the repo and wires it into docker compose up.
  • Per-tenant overrides (Stripe keys, OAuth tokens, GA IDs) are encrypted with OPS_ENCRYPTION_KEY (a Fernet key) and stored by the control plane, then injected at runtime when each tenant's container starts.
  • Cloudflare and SHARED_HOST_IP values come from a separate ops vault, never from a contributor's laptop.
WARNING
Never commit .env, .env.local, or any file containing real secrets. bootstrap/.env is git-ignored. bootstrap/.env.example is the only env file safe to track — and it must contain only placeholders.
TIP
Rotate JWT and cookie secrets per environment. A compromised MEDUSA_JWT_SECRET means anyone can forge admin tokens. Use openssl rand -hex 32 for dev secrets; production secrets should come from your secret manager (1Password, Doppler, AWS Secrets Manager).
THEMES

Build a theme in 30 minutes

End-to-end walkthrough — from clone to live preview.

Themes in Nuvi are split into two halves: a JSON config (tokens, sections, defaults) at packages/modules/theme/src/lib/theme-configs/<slug>.json and a React component tree at bootstrap/tenant-front/src/themes/<slug>/. Together they define how a store looks and renders.

Step 1 — Copy the starter

Start from the existing starter theme — it has the cleanest minimal shape:

# JSON config
cp packages/modules/theme/src/lib/theme-configs/starter.json \
   packages/modules/theme/src/lib/theme-configs/mybrand.json

# React components
cp -r bootstrap/tenant-front/src/themes/sdk \
      bootstrap/tenant-front/src/themes/mybrand

Step 2 — Update identity

Open the new mybrand.json. Change id, name, description, author. The id field is the URL-safe slug used everywhere — keep it lowercase and hyphenated.

Step 3 — Tweak tokens

Inside tokens.colors change the accent palette. The CSS layer auto-generates var(--color-primary), var(--color-foreground), etc. for every entry. Update tokens.colorsDark if you support dark mode.

Step 4 — Wire your sections

Open bootstrap/tenant-front/src/lib/section-map.ts and register your sections under a comment block for your theme:

// ── Mybrand sections ──
'mybrand-header': () => import('@/themes/mybrand/MybrandHeader'),
'mybrand-hero': () => import('@/themes/mybrand/MybrandHero'),

Step 5 — Preview

Spin up the dev stack: cd bootstrap && docker compose up -d. Visit http://localhost:3000, log into the admin at http://localhost:9000/app, and select Settings → Themes → Mybrand. Changes hot-reload.

TIP
The nuvi-base and starter themes are intentionally minimal. Most production themes (usenuvi, editorial, gourmet) carry 15–30 sections — copy from the closest match.
THEMES

Theme anatomy

What lives where, and why.

A complete theme touches four locations in the repo:

Theme file map
packages/modules/theme/src/lib/theme-configs/
  └── mybrand.json                # JSON config — single source of truth

bootstrap/tenant-front/src/themes/mybrand/
  ├── manifest.ts                 # Self-declares sections + theme metadata
  ├── tokens.ts                   # cs helper — radius / padding / type scale
  ├── schemas.ts                  # Zod schemas owned by this theme
  ├── locales/
  │   ├── en.json                 # Theme-specific string overrides
  │   └── tr.json
  └── sections/
      ├── MybrandHeader.tsx       # React components per section type
      ├── MybrandHero.tsx
      └── MybrandFooter.tsx

bootstrap/medusa/src/lib/
  └── section-schemas/mybrand.ts  # Admin-facing schemas (form fields)

The JSON config is the source of truth. It declares the theme's identity, design tokens, and a default sections array — every page rendered with this theme starts from there. The runtime merges DB overrides on top (when an admin saves customisations).

The React components are dumb renderers. They read their config from props (section.settings) and render JSX. No state, no data fetching — just layout and styling.

The Zod schemas are the contract between admin and storefront. They validate JSON config at build time and at API boundaries; they also drive the admin section-editor form fields.

NOTE
Sections are theme-prefixed by convention (usenuvi-header, gourmet-product-carousel) so the same section type doesn't collide across themes. Shared sections (header, hero, footer) live under themes/shared/ and any theme can reuse them.
THEMES

Design tokens

Colors, typography, spacing, dark mode.

Tokens are JSON values that turn into CSS custom properties at runtime. The shape:

tokens block
{
  "tokens": {
    "colors": { "primary": "#000", "background": "#FFF", "foreground": "#000", … },
    "colorsDark": { "primary": "#FFF", "background": "#000", "foreground": "#FFF", … },
    "typography": {
      "fontFamily": { "sans": "Inter, sans-serif", "serif": "Lora, serif" },
      "fontSize": { "xs": "0.75rem", "base": "1rem", "5xl": "3.5rem" }
    },
    "spacing": { "xs": "0.25rem", "md": "1rem", "3xl": "4rem" },
    "borderRadius": "0.5rem",
    "shadows": { "sm": "...", "lg": "..." },
    "layout": { "containerMax": "1200px", "headerHeight": "64px" }
  }
}

How it lands in CSS

The SSR theme helper expands every leaf into --<namespace>-<key>. tokens.colors.primary becomes --color-primary; tokens.layout.headerHeight becomes --layout-header-height. Dark variants apply when the user's mode is dark (or when the theme is dark-only).

Using tokens in a section

<div style={{
  background: 'var(--color-background)',
  color: 'var(--color-foreground)',
  padding: 'var(--spacing-lg)',
  borderRadius: 'var(--border-radius)',
}}>
  …
</div>
TIP
Avoid hard-coding hex values in components. Always reference var(--color-*) so the theme editor can let admins tweak without code changes — and so light/dark behaves consistently.
THEMES

Anatomy of a section

Schema + component + admin schema.

A section has three coupled pieces. Build them in this order:

1. The Zod schema (storefront)

Inside themes/mybrand/schemas.ts:

import { z } from 'zod'
import { BaseSectionPropsSchema } from '@/themes/shared/schemas'

export const MybrandHeroSectionSchema = BaseSectionPropsSchema.extend({
  type: z.literal('mybrand-hero'),
  settings: z.object({
    badge: z.string().optional(),
    headline: z.string(),
    subheading: z.string().optional(),
    primaryCtaText: z.string().optional(),
    primaryCtaHref: z.string().optional(),
  }),
})
export type MybrandHeroSection = z.infer<typeof MybrandHeroSectionSchema>

2. The React component

Inside themes/mybrand/sections/MybrandHero.tsx:

'use client'
import type { z } from 'zod'
import type { MybrandHeroSectionSchema } from '@/themes/mybrand/schemas'
import { SectionComponentProps } from '@/lib/theme-types'

type Props = SectionComponentProps<z.infer<typeof MybrandHeroSectionSchema>>

export default function MybrandHero({ section }: Props) {
  const { settings } = section
  return (
    <section style={{ padding: '4rem 1.5rem', textAlign: 'center' }}>
      {settings.badge && <span>{settings.badge}</span>}
      <h1>{settings.headline}</h1>
      {settings.subheading && <p>{settings.subheading}</p>}
    </section>
  )
}

3. Register it

Add the section to your theme's manifest.ts (the runtime composes the global section map from every theme's manifest — there's no central registry to edit anymore):

// themes/mybrand/manifest.ts
import type { ThemeManifest } from '@/themes/sdk/types'

export const mybrandManifest: ThemeManifest = {
  id: 'mybrand',
  displayName: 'My Brand',
  namespaces: ['mybrand-'],
  sections: {
    'mybrand-hero': () => import('./sections/MybrandHero'),
    // …other sections
  },
}

Then add mybrandManifest to THEME_MANIFESTS in src/themes/index.ts. For admin editor support, add a matching schema under bootstrap/medusa/src/lib/section-schemas/mybrand.ts declaring the form fields.

WARNING
The type string in the Zod schema must match the key in your manifest.ts, the JSON config's section.type, AND the admin schema's type. Drift between any of these silently breaks rendering. The audit-theme-isolation.mjs script also catches cross-theme imports.
THEMES

Blocks: repeated child items

Modeling lists inside sections.

Most sections need a list of child items: feature cards, testimonials, pricing tiers, footer columns. Two patterns supported:

Pattern A — inline settings array (legacy)

Define the list as a Zod array on settings:

settings: z.object({
  heading: z.string().optional(),
  features: z.array(z.object({
    icon: z.string().optional(),
    title: z.string(),
    description: z.string(),
  })).default([]),
})

Pattern B — block tree (recommended)

Use the blocks array on the section. Each block has a type and its own typed settings:

{
  "id": "features-1",
  "type": "mybrand-feature-grid",
  "settings": { "heading": "Why us" },
  "blocks": [
    { "type": "feature-item", "order": 0, "settings": { "title": "Fast", "description": "Sub-second loads" } },
    { "type": "feature-item", "order": 1, "settings": { "title": "Quiet", "description": "No upsells" } }
  ]
}

In the component, use resolveBlockItems() from @/lib/blocks-resolver:

import { resolveBlockItems } from '@/lib/blocks-resolver'

const features = resolveBlockItems<{ title: string; description: string }>(
  section.blocks,
  settings.features,        // legacy fallback
  'feature-item',
)
TIP
Block pattern wins when admins drag-drop / reorder items in the editor. Inline settings arrays are simpler but harder to edit visually. Modern themes (usenuvi, editorial) use blocks throughout.
THEMES

i18n in your theme

Bilingual EN/TR via the ut() helper.

Themes support per-locale text overrides without forking the config. Two pieces:

1. Wire the helper

Inside your section component, call useUsenuviLocale() (or your theme's equivalent) to get the ut() function:

import { useUsenuviLocale } from './use-usenuvi-locale'

export default function MybrandHero({ section }: Props) {
  const { ut } = useUsenuviLocale()
  const headline = ut(section.id, 'headline', section.settings.headline)
  return <h1>{headline}</h1>
}

2. Add locale entries

Inside bootstrap/tenant-front/src/locales/tr.json (and en.json if you have non-English source copy):

{
  "mybrand": {
    "hero-1": {
      "headline": "Build any website",
      "subheading": "AI-powered, one click setup."
    }
  }
}

The ut() call looks up mybrand.<sectionId>.<field> in the active locale; falls back to the JSON config value if the override is absent. Authors can override per-locale without touching code.

NOTE
For block lists, the key includes the index: blocks.0.title, blocks.1.title. Same pattern for nested arrays — columns.2.links.4.label.
THEMES

Testing + publishing

From local preview to a live tenant.

NOTE
Today there is no Nuvi CLI and no separate theme package format. Themes live inside the monorepo and ship as part of the medusa + tenant-front Docker images. A @nuvi/theme-* package model and an installer CLI are on the roadmap, not yet started.

Local preview

  1. Files in place: JSON config, React components, section-map registration, Zod schemas, admin section-schemas.
  2. cd bootstrap && docker compose up -d --build medusa tenant-front — both images rebuild with your changes (medusa picks up the new JSON + admin schema, tenant-front picks up the components + Zod schema).
  3. http://localhost:9000/appSettings → Themes → Mybrand → Activate.
  4. Visit http://localhost:3000 — your theme renders. Next.js dev mode handles hot-reload for tenant-front; medusa requires a container rebuild for module-package changes.

Shipping to a live tenant

  1. Open a PR against the monorepo. CI runs tsc --noEmit across tenant-front + medusa, plus image builds for any service whose paths changed.
  2. Merge to main. GitHub Actions pushes new ghcr.io/<org>/medusa and ghcr.io/<org>/tenant-front images.
  3. deploy.sh on the production server pulls those images and recreates each tenant's containers (e.g. medusa-usenuvi, front-usenuvi).
  4. On the tenant: Settings → Themes → Mybrand → Activate. The theme JSON is bundled into the new medusa image, so it shows up in the gallery automatically.
WARNING
Switching themes on an existing store changes which sections are valid — sections from the previous theme get filtered out at the /store/theme API layer. Heavy admin customisations should be documented or migrated explicitly before switching.

What doesn't exist (yet)

  • No nuvi theme dev / nuvi theme build CLI — Next.js dev server + docker compose are the tools today.
  • No create-nuvi-theme generator — the way to start a new theme is to copy starter or the closest production theme.
  • No theme marketplace — themes are repo contributions for now.
  • The @nuvi/module-sdk package exists (manifest validator + types for module authors) but it's a library, not a CLI.

If a CLI / package model would unblock you, let us know on the contact page — it influences roadmap priority.

AI

Writing a custom AI skill

Define a Skill, register it, ship it with the agent.

The AI assistant is built around Skills — Python objects that bundle a name, description, Gemini function declarations, executor mappings, and a system prompt fragment. The repo ships ~24 skills under bootstrap/nuvi-agent/app/skills/ covering SEO, theme editing, marketing, dropshipping, and more. Adding your own takes one file and a one-line registration.

1. Drop a new file

Create bootstrap/nuvi-agent/app/skills/<your_skill>.py. Use seo_auditor.py as a clean reference. Build the Skill instance and call register_skill() on it:

bootstrap/nuvi-agent/app/skills/inventory_helper.py
from ..tools.declarations import list_products, update_product
from .base import Skill
from .registry import register_skill

inventory_helper = Skill(
    id="inventory_helper",
    name="Stok Yardımcısı",
    description="Düşük stoklu ürünleri bulur ve güncellemeyi önerir.",
    icon="package",
    category="commerce",
    model_tier="flash",
    quick_actions=(
        {"label": "Düşük stok", "prompt": "Düşük stoklu ürünleri listele",
         "icon": "alert-triangle"},
    ),
    translations={
        "en": {"name": "Inventory Helper",
               "description": "Find low-stock products and propose updates."},
    },
    declarations=(list_products, update_product),
    tool_map={
        "list_products":  ("medusa.read",  "list_products"),
        "update_product": ("medusa.write", "update_product"),
    },
    system_prompt="""You are an inventory assistant. When asked, call list_products
filtered by inventory_quantity < 10, then summarise.""",
    required_modules=("inventory",),
)

register_skill(inventory_helper)

2. Field reference

The shape lives in app/skills/base.py. Key fields:

  • id — lowercase, digits, underscores (regex enforced). Must be unique.
  • name, description, icon, category — surfaced in the dashboard skill picker.
  • declarations — tuple of Gemini function declarations from app/tools/declarations.py.
  • tool_map — maps each declaration name to (executor, action); this is how the agent routes a function call to the right backend (medusa.read, medusa.write, theme, etc.).
  • system_prompt + localized_prompts — appended to the model's system instructions when this skill is active.
  • required_modules — skill is hidden in the UI when any listed module is disabled for the store.
  • model_tier"flash" for fast daily tasks, "pro" for harder reasoning.

3. Make it import

Skills register at import time via register_skill(). Import your file from app/skills/__init__.py (the existing skills follow the same pattern) so it loads when the agent boots.

4. Test in the local stack

  1. cd bootstrap && docker compose up -d --build nuvi-agent — Python image rebuilds with your new file.
  2. Open the AI assistant in the Medusa admin (http://localhost:9000/app/ai) and confirm the skill shows up in the picker.
  3. Trigger a quick action; check docker compose logs -f nuvi-agent for the function call dispatch line.
NOTE
New executor actions (anything beyond what medusa_executor.py and theme_executor.py already expose) need a matching handler on the executor side. Skills are wiring; capabilities live in executors.
SDK

Using the JS SDK

Storefront client for browser + Node.

The official Nuvi JS SDK wraps the storefront API in typed methods. Install it:

Install
npm install @nuvi/storefront-sdk
Initialize and fetch products
import { NuviClient } from '@nuvi/storefront-sdk'

const nuvi = new NuviClient({
  baseUrl: 'https://yourstore.com/api',
  publishableKey: 'pk_live_...',
})

const { products, count } = await nuvi.products.list({ limit: 20 })

Authentication for customer-scoped methods:

Customer login + me
const { token } = await nuvi.auth.login({ email, password })
nuvi.setCustomerToken(token)
const me = await nuvi.customers.me()
NOTE
The SDK is in beta. The full REST API is stable and works from any language. Native SDKs for Python and PHP are on the roadmap.
RECIPES

Building a B2B store with the Quote module

Quote-to-cash flow: customer requests, admin review, PDF + email, ISG pricing.

Many B2B sellers don't list public prices — they negotiate. The @nuvi/module-quote package wraps the entire quote-to-cash loop: a public request endpoint, an admin review screen, branded PDFs, and email delivery. The data model lives in packages/modules/quote/src/module/models/ as quote, quote-item, service-template, service-category, and isg-config.

1. Customer submits a request

Drop a quote-request form on any page (the module ships a quote-request storefront section) and POST to the public endpoint. The handler validates name, email, and phone server-side, recalculates prices from your service templates, generates a unique quote_number, and runs quoteRequestWorkflow to persist the draft.

POST /store/quote-requests
await fetch('/store/quote-requests', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    customer_name: 'Acme Ltd',
    customer_email: '[email protected]',
    customer_phone: '+90 555 000 00 00',
    customer_company: 'Acme Ltd',
    items: [
      { service_template_id: 'st_01H...', title: 'Risk Analysis', quantity: 1, unit_price: 2500 },
    ],
  }),
})

2. Admin reviews and prices

The admin opens /app/quotes, edits line items, and updates status (draft → sent → accepted). The lifecycle states live in the status enum on the quote model: draft, sent, accepted, rejected, expired. valid_until drives expiry.

3. PDF + email delivery

Hitting GET /admin/quotes/:id/pdf renders the branded PDF. The store-side share link uses a signed pdf_token generated in the workflow, so customers can fetch their own quote without logging in. Notification recipients are configured via POST /admin/quotes/notification-recipients.

4. ISG pricing — a worked example

Turkish OHS (occupational health & safety) firms charge by employee count and risk class. The isg-config model stores service_durations, fulltime_thresholds, nurse_durations, and default_tax_rate. When a request includes risk_class + employee_count, the route calls calculateIsgPrice() server-side — frontend prices are ignored. Edit the matrix from /app/services or PUT /admin/quotes/isg-config.

5. Quote-to-cash hand-off

Once a quote is accepted, link it to a Medusa order or subscription via quote.metadata. A common pattern: copy quote_items to draft order line items, then send a Stripe / Iyzico payment link. Recurring service contracts — pair the accepted quote with a subscription_plan from the subscription module to bill monthly.

WARNING
The store endpoint always recalculates ISG prices from the DB-stored isg-config. Never trust unit_price from the client — the handler uses it only as a hint for non-ISG line items.
RECIPES

Subscription commerce — recurring billing

Plans, trials, dunning, lifecycle, and a customer portal — wired to Stripe.

The @nuvi/module-subscription package gives you recurring billing, customer portal, and lifecycle hooks on top of Medusa. Three models drive everything: subscription_plan, customer_subscription, and subscription_payment (history). Stripe is the default provider — stripe_price_id on the plan binds the two systems.

1. Create a plan

Plans are managed at /app/subscriptions or via POST /admin/subscriptions/plans. The interval enum is daily | weekly | monthly | yearly, multiplied by interval_count. trial_days drives the trial window; billing_cycles caps the total renewals (set null for evergreen).

POST /admin/subscriptions/plans
{
  "name": "Pro Monthly",
  "price": 49,
  "currency": "USD",
  "interval": "monthly",
  "interval_count": 1,
  "trial_days": 14,
  "stripe_price_id": "price_1Ox..."
}

2. Customer signs up

The storefront calls POST /store/subscriptions/checkout with plan_id, success_url, and cancel_url. The handler validates the redirect URLs against STORE_CORS / ADMIN_CORS (open-redirect guard) and creates a Stripe Checkout Session. After the customer pays, Stripe webhooks insert a customer_subscription with status trialing or active.

3. Trials and dunning

trial_end is set when the customer is in trial. On failed renewals, status flips to past_due and the dunning policy (Stripe smart retries by default) kicks in. After exhaustion, status becomes canceled or expired.

4. Lifecycle endpoints — customer portal

The customer portal hits four lifecycle routes:

Customer-facing routes
POST /store/subscriptions/:id/pause
POST /store/subscriptions/:id/resume
POST /store/subscriptions/:id/change-plan   { plan_id }
POST /store/subscriptions/:id/cancel        { at_period_end?: boolean }

Cancel with at_period_end: true sets cancel_at_period_end on the row — the subscription stays active until current_period_end, then flips to canceled. Pause sets paused_at; resume clears it and re-issues the next billing date.

5. Webhooks on renewals

Each successful renewal writes a subscription_payment record. Hook your own logic — emit emails, top up usage credits, or reconcile against your own ledger — by listening for the subscription.payment.succeeded event in the module's event bus.

6. Pairing with products

subscription_plan.product_id can point at a Medusa product, so the same SKU drives both one-off and recurring purchases. The plan's features JSON column is a free-form bag — site_limit, mailbox_limit, etc. — used for entitlement checks elsewhere in your app.

TIP
Webhook signature verification is fail-closed. If STRIPE_WEBHOOK_SECRET is unset the route returns 500. Always set it in every environment, including staging.
RECIPES

Tour platform with the Travel module

Tours, destinations, bookings, and reviews — paired with the travel theme.

@nuvi/module-travel turns Nuvi into a tour-operator platform. The data model mirrors how tour businesses think: a tour has itinerary days, included / excluded arrays, highlights, a guide block, and pricing. Tours connect to destination records (many-to-many via tour-destination) and to tour-category taxonomy.

1. The tour entity

Defined in packages/modules/travel/src/module/models/tour.ts. Key fields: slug (unique per store), duration_days, max_group_size, price_from, price_per_guest, plus rich JSON columns for itinerary, included, excluded, and quick_facts. The status enum is draft | published | archived.

2. Destinations and categories

Destinations are reusable places — Cappadocia, Patagonia, Mt. Fuji. Public listing via GET /store/destinations and detail via GET /store/destinations/:slug. Categories (city break, adventure, family) feed sidebar filters via GET /store/tour-categories.

3. Booking flow

Bookings reuse the Medusa cart. Add a tour with the dedicated cart line endpoint:

Add a tour to cart
POST /store/carts/:id/tour-items
{
  "tour_id": "tour_01H...",
  "departure_date": "2026-07-15",
  "guest_count": 2,
  "options": { "transfer": true }
}

The line item carries the tour data through standard Medusa checkout — payment, address, confirmation. After the order is placed, you can render the booking confirmation by joining the order's metadata back to the tour record.

4. Reviews

The storefront fetches approved reviews via GET /store/tours/:slug/reviews. The handler filters by status: 'approved' only — drafts and rejected reviews never leak. rating and review_count on the tour itself are denormalised aggregates updated by the moderation flow.

5. Admin authoring

/app/travel provides the editor. Slug uniqueness is checked via GET /admin/tours/check-slug. Pricing and per-departure overrides live under /admin/tours/:id/pricing; the moderation queue is at /admin/tours/:id/reviews.

6. Pairing with the travel theme

The travel theme ships with section components matched to this data: hero with destination search, tour grid, itinerary day-by-day, included/excluded checklists, and a guide card. Register them in bootstrap/tenant-front/src/lib/section-map.ts and the editor exposes them under the tour-grid section listed in the manifest.

NOTE
The reviews endpoint enforces a 1–100 limit on page size and a default of 20 — wire infinite scroll on the storefront, not a dump-all listing.
RECIPES

Events + ticketing platform

Events, ticket types, registrations, attendee list, and a customer portal.

@nuvi/module-events ships event publishing plus ticketing. Three core models: event (the show), event-ticket-type (price tiers + capacity), and event-registration (a sold seat). The storefront sees a public event detail page; the admin sees attendee lists, sales reports, and check-in.

1. The event entity

Defined in packages/modules/events/src/module/models/event.ts. The model carries scheduling (starts_at, ends_at, timezone, is_all_day), venue (venue_name, venue_address, venue_city), an online flag with online_url, and a top-level capacity. Status: draft | published | cancelled | completed. registration_enabled gates ticket sales without unpublishing the page.

2. Ticket types

Each event has many event_ticket_type rows: Early Bird, GA, VIP. Per-tier price, capacity, and registered_count drive availability. sales_start_at and sales_end_at implement sales windows. The store-side endpoint computes derived flags so the storefront doesn't have to:

GET /store/events/:slug/tickets
{
  "registration_enabled": true,
  "ticket_types": [
    {
      "id": "ett_01H...",
      "name": "Early Bird",
      "price": 49,
      "available": 23,
      "is_sold_out": false,
      "is_within_sales_window": true
    }
  ]
}

3. Cart + checkout

Tickets travel through the standard Medusa cart via the dedicated event-line endpoint:

Add a ticket to cart
POST /store/carts/:id/event-items
{
  "event_id": "evt_01H...",
  "ticket_type_id": "ett_01H...",
  "quantity": 2,
  "attendee_name": "Jane Doe",
  "attendee_email": "[email protected]"
}

On order completion the module creates an event_registration per ticket with a unique confirmation_number. Status flows: pending_payment → confirmed → checked_in (or cancelled).

4. Attendee list and check-in

The admin reads the attendee list from GET /admin/events/:id/registrations and sales totals from GET /admin/events/:id/stats. Door staff hit a check-in endpoint that flips status to checked_in and stamps checked_in_at.

5. Customer portal — my registrations

Authenticated customers see all their tickets — past and future — from a single endpoint:

GET /store/events/my-registrations
// Auth required. Server resolves the customer's email
// from the session, then looks up registrations by attendee_email.
{
  "registrations": [
    {
      "id": "reg_01H...",
      "confirmation_number": "EVT-2026-00042",
      "status": "confirmed",
      "event": { "title": "Spring Summit", "starts_at": "..." },
      "ticket_type": { "name": "VIP" }
    }
  ]
}

6. Post-event communication

Combine with the email-marketing module to message attendees: query event_registrations filtered by event_id and status: 'checked_in', then push a recording link or feedback survey. Cancel an event by flipping the status enum to cancelled — the my-registrations endpoint surfaces the new state immediately.

TIP
The my-registrations route is fail-closed: if the request has no auth_context.actor_id it returns 401, never an empty list. Always wrap it in an authenticated fetch on the client.

Looking for endpoint details?

The full REST API reference covers every storefront endpoint, with method tags, request shapes, and cURL samples.

Open API reference