Build a custom website with Vue

This guide builds a complete website for selling software with the Moonbase Vue.js SDK, with every screen your own: product pages and prices, the cart and its offers, sign-in and sign-up, downloads and free trials, activating a plugin, and an account area. Moonbase takes the payment, in its checkout, which opens over your page.

The example is Corino, a fictional maker of audio plugins, and its site is open source. It is a Nuxt 4 app, live at corino-vue.moonbase.sh against a demo Moonbase account. It sells two plugins, HALO and DRIFT, and a bundle of both, with a 14-day free trial of each, a cart offer of 25% off orders over €200, and an offer that suggests the bundle to anyone buying one of the plugins. Every snippet below is taken from it, so you can read each one in place.

The Corino site in Vue, with its own cart open: HALO, the bundle offered in its place, and how far the cart is from 25% off

The Corino site: a Nuxt 4 storefront on the Moonbase Vue.js SDK, against a public demo account.

If you'd rather not build the cart, account area and sign-in yourself, the embedded storefront draws them for you over your own pages.

Before you start

You need three things from your Moonbase account:

  • Your account's address, https://<account-id>.moonbase.sh. It is where the SDK loads your catalog from and signs customers in. Corino's is https://corino-demo.moonbase.sh.
  • Your product and bundle IDs, from each one's page in Moonbase. Corino's are halo, drift and duo-bundle.
  • Your site's address on the allow list, under Account settings > Storefront > "Domain configuration". Your store answers localhost while you build, but no other address until you add it.

Moonbase sends customers links: to confirm their account, to reset a password, to download what they bought, and, from inside a plugin, to activate it. To have those links open your site, set the storefront mode under Account settings > Storefront to Custom integration and give the page for each. These are Corino's:

LocationPageIt gets
Log in/login
Reset password/forgot-passwordemail and code
Confirm account/sign-upemail and code
Confirm email/confirm-emailemail and code
Confirm email change/account/confirm-email-changeemail and code
Activate in browser/activatetoken
Activate offline device/activate
Download product (optional)/downloadproduct_id, and version and key for one file
Manage subscription (optional)/subscriptionssubscription_id

While an optional location is empty, its links go to Moonbase's hosted customer portal. So does the optional Checkout location, for orders started elsewhere, which Corino leaves empty.

Install and connect

Add the SDK to your app:

npm install @moonbase.sh/vue

createStorefront connects to your store, and installing it as a Vue plugin makes it available to every component. In Nuxt, do both in a plugin. Its second argument is where the SDK keeps its state: Nuxt's useState carries the catalog, the signed-in customer and the cart from the server into the page, so the first paint already has real prices. On the server, wait for the catalog before rendering:

app/plugins/moonbase.ts

import { createStorefront } from '@moonbase.sh/vue'

export default defineNuxtPlugin(async (nuxtApp) => {
  const storefront = createStorefront(
    nuxtApp.$config.public.moonbaseEndpoint as string,
    (key, state) => useState(key, () => state),
    { persistUtm: true },
  )

  if (import.meta.server)
    await storefront.updateStorefront()

  nuxtApp.vueApp.use(storefront)
})

Pages that depend on the visitor rather than on your catalog (the account area, and the pages your Moonbase links open, which read their parameters from the address) gain nothing from rendering on the server. Corino renders them in the browser only:

nuxt.config.ts

export default defineNuxtConfig({
  routeRules: {
    '/login': { ssr: false },
    '/sign-up': { ssr: false },
    '/forgot-password': { ssr: false },
    '/activate': { ssr: false },
    '/account/**': { ssr: false },
    // …and the other callback pages
  },
})

In a Vue app without Nuxt, create the storefront in a module of its own and use it before the router, which you make in a function so it comes after the storefront:

src/main.ts

import { createStorefront } from '@moonbase.sh/vue'
import { createApp } from 'vue'
import App from './App.vue'
import { createAppRouter } from './router'

const storefront = createStorefront('https://<account-id>.moonbase.sh')

createApp(App)
  .use(storefront)
  .use(createAppRouter(storefront))
  .mount('#app')

A customer who pays on Moonbase's hosted checkout (on a phone, say) comes back to your page with a few parameters in its address, which the storefront reads and takes off as it is created. Vue Router reads the address once, as it is created, so a router made first would put them back.

Show products and prices

useProduct gives you a product from your catalog, with its prices, discounts and, for a signed-in customer, whether they own it. useBundle does the same for a bundle. Each card on Corino's landing page is one PluginCard.vue, given the product's ID:

import { formatMoney, useCart, useProduct } from '@moonbase.sh/vue'

const props = defineProps<{ productId: string, fallbackPrice: string }>()

const product = useProduct(props.productId)
const cart = useCart()

const variation = computed(() => product.value?.defaultVariation || null)
const currency = computed(() => cart.currency.value)

// Until the catalog has a price in the cart's currency, show the one the page was written with
const price = computed(() => {
  const amount = variation.value?.price[currency.value]
  return amount === undefined ? props.fallbackPrice : formatMoney(amount, currency.value)
})

A product's defaultVariation and variations are its pricing options. Each has a price and an originalPrice for every currency your store sells in. Show them in the currency the cart uses, which is the one your store suggests for the visitor and the one the checkout charges, and write them with formatMoney, as the checkout does. Keep a price in your own words for the moment before the catalog arrives, and in case your store can't be reached.

useProduct also takes a ref or a getter, and follows it. CartLine.vue renders both kinds of cart line, so it looks up whichever one it has:

// Each gives null for the other kind of line, whose ID the catalog doesn't have
const product = useProduct(() => props.item.type === 'Product' ? props.item.productId : '')
const bundle = useBundle(() => props.item.type === 'Bundle' ? props.item.bundleId : '')

Call them like this, once in setup, rather than inside a computed. They find the storefront with inject(), which Vue only allows while setting up a component, so a computed that calls one throws as soon as a watcher or a click handler reads it.

What a customer owns changes what you offer them. The catalog is loaded again after they sign in, sign out or buy something, so product.owned follows on its own, and Corino's card swaps its button:

<button v-if="!product?.owned" type="button" :disabled="!product" @click="addToCart">
  Add to cart
</button>
<button v-else type="button" @click="showDownloads(productId)">
  Download
</button>

Pricing options

Corino sells one pricing option per product, its default. If you sell more, such as a monthly rent-to-own plan beside a perpetual license, find it in variations by its ID and put it in the cart with addToCart(product, variation). Read what the plan promises from the option itself rather than writing it on the page, so the page says what your store actually sells:

const variation = computed(() => product.value?.variations?.find(variation => variation.id === 'rent-to-own'))

// After how many payments the license is theirs to keep
const payments = computed(() => {
  const recurrence = variation.value?.recurrence
  if (recurrence?.type !== 'Recurring')
    return undefined
  return recurrence.milestoneConfiguration?.milestones
    .find(milestone => milestone.type === 'PerpetualLicenseConversionMilestone')
    ?.afterCycleNumber
})

The cart

useCart is the cart: its items, addToCart, removeFromCart, and its totals. It is kept in the browser, so it survives a reload and a sign-in. Corino's cart is a drawer, CartDrawer.vue, with a CartLine.vue for each line.

import { useCart } from '@moonbase.sh/vue'

const cart = useCart()
// cart.items.value: each line, with its product or bundle and its pricing option
// cart.breakdown.value: { original, subtotal, cartOffer, discount, amount } in cart.currency.value
// cart.soldOutItems.value: lines that have to come out before checking out

breakdown adds the cart up: original at list prices, subtotal after discounts on single items, the cart offer's saving in cartOffer, and amount, the total the checkout starts from. Corino shows each on its own row, so a total lower than the lines explains itself:

<dl v-if="breakdown.amount !== breakdown.original">
  <div>
    <dt>Subtotal</dt>
    <dd>{{ money(breakdown.original) }}</dd>
  </div>
  <div v-if="breakdown.original > breakdown.subtotal">
    <dt>Discounts</dt>
    <dd>−{{ money(breakdown.original - breakdown.subtotal) }}</dd>
  </div>
  <div v-if="breakdown.cartOffer">
    <dt>{{ breakdown.cartOffer.offer.discount.name }}</dt>
    <dd>−{{ money(breakdown.cartOffer.discount) }}</dd>
  </div>
</dl>
<p>Total {{ money(breakdown.amount) }}</p>

Here money is amount => formatMoney(amount, breakdown.value.currency). Taxes are worked out in the checkout, from the buyer's billing address, which is also where coupon codes go.

When you add a bundle, pass { bundleBehaviour: 'replace' } so it takes the place of the products it includes that are already in the cart, rather than selling them twice:

cart.addToCart(bundle.value, undefined, undefined, { bundleBehaviour: 'replace' })

Offers

Offers you set up in Moonbase come in two kinds. A cart offer, such as Corino's 25% off orders over €200, applies to the whole cart once it qualifies, and the SDK attaches the best one for you. useCartOffer says which one applies and which one the cart is closest to, with how far it has to go:

import { formatMoney, useCartOffer } from '@moonbase.sh/vue'

const cartOffer = useCartOffer()

// "Add €71 more for Big Spender"
const nextOffer = computed(() => {
  const next = cartOffer.value?.next
  if (!next || next.progress.unit !== 'Money')
    return null
  return {
    name: next.offer.discount.name,
    remaining: formatMoney(next.progress.remaining, next.progress.currency!),
    fraction: next.progress.fraction, // for a progress bar
  }
})

An item offer, such as Corino's bundle for anyone buying HALO or DRIFT, discounts one item when the cart holds others. useEligibleOffers gives the ones the cart qualifies for and doesn't hold yet. Show what the item would cost with the offer, and pass the offer to addToCart. This is the core of Corino's CartUpsells.vue:

import { DiscountUtils, formatMoney, MoneyCollectionUtils, useCart, useEligibleOffers } from '@moonbase.sh/vue'

const cart = useCart()
const offers = useEligibleOffers()

const upsells = computed(() => offers.value
  .filter(offer => offer.scope === 'Item')
  .flatMap((offer) => {
    const item = offer.targets[0]?.item
    const variation = item?.defaultVariation
    if (!item || !variation || item.owned)
      return []
    const price = MoneyCollectionUtils.subtract(variation.price, DiscountUtils.apply(offer.discount, variation.price))
    return [{ offer, item, variation, price: formatMoney(price[cart.currency.value]!, cart.currency.value) }]
  }))

// In the template: @click="cart.addToCart(upsell.item, upsell.variation, upsell.offer, { bundleBehaviour: 'replace' })"

Checking out

cart.checkout() hands the cart to Moonbase's checkout. With redirect: 'auto', the default, it opens over your page on a computer and as Moonbase's own page on a phone, where Apple Pay and Google Pay work. Corino passes redirect: false, so it always opens over the page. In Nuxt the router is set up before your plugins run, so the parameters a customer brings back from the hosted checkout would come back into that history entry, and the overlay avoids the round trip:

await cart.checkout({ redirect: false, returnUrl: route.path })

Either way, the cart empties once the order is paid for. To thank the customer, listen for the completed order. Corino does it in its plugin, in the browser only, and closes the drawer and the checkout:

if (import.meta.client) {
  storefront.onCheckoutCompleted(() => {
    useUi().hideCart()
    storefront.closeCheckout()
    useRouter().push('/order-completed')
  })
}

onCheckoutCompleted also fires as the page loads again after the hosted checkout. Each on… hook returns a function that stops it, for a hook set up in a component that goes away.

Sign-in and accounts

useAuth has the signed-in customer, user, and everything around signing in. loaded turns true once the SDK knows whether anyone is signed in, so Corino's header holds back "Log in" until then rather than show it to a customer who is:

<button v-if="loaded && !user" type="button" @click="showLogin">Log in</button>
<NuxtLink v-else-if="user" to="/account">{{ user.name || 'My account' }}</NuxtLink>

Its sign-in modal calls signIn(email, password) and shows the error message on failure.

Signing up

Corino's /sign-up page is both the sign-up form and, opened from the email with email and code, the "Confirm account" page. Signing up can wait on the customer's email: when your store confirms addresses, signUp resolves with status: 'activation-pending' and Moonbase sends a link to your "Confirm account" page. An address that already has an account without a password (one made at checkout, say) throws PasswordSetupRequiredError, and Moonbase sends the same link: opened in the same browser, it sets the password they just typed.

import { PasswordSetupRequiredError, useAuth } from '@moonbase.sh/vue'

const { signUp } = useAuth()

try {
  // The two `true`s: they accept the privacy policy and the terms
  const result = await signUp(name, email, password, null, true, true, newsletterOptIn)
  if (result.status === 'activation-pending')
    showCheckYourEmail()
  else
    await router.replace('/account')
}
catch (err) {
  if (err instanceof PasswordSetupRequiredError)
    showCheckYourEmail()
  else
    error.value = (err as Error).message
}

Say beside the button that signing up accepts your terms and privacy policy, with links to them: Moonbase hosts both, at https://<account-id>.moonbase.sh/terms-of-use and /privacy-policy. The server refuses a weak password with a message you can show as it is.

The same page confirms the code from the link. Most of the time that signs the customer in. For an account that still has no password, opened in another browser say, it comes back with a token to set one:

const { confirmAccount, resetPassword, signIn } = useAuth()

const result = await confirmAccount(email, code)
if (result.status === 'PasswordSetupRequired') {
  // Ask for a password, then:
  await resetPassword(result.email, password, result.resetPasswordToken!)
  await signIn(result.email, password)
}

forgotPassword(email) sends the link to your "Reset password" page, which calls resetPassword(email, newPassword, code). Corino's /forgot-password does both, depending on whether the link's parameters are there. confirmEmail and confirmEmailChange finish the other two links.

Keeping the account area for customers

Corino's account pages sit under one parent page, pages/account.vue, which shows the tabs to a signed-in customer and a sign-in button to anyone else, once loaded says which it is:

<div v-if="!loaded">Loading…</div>
<div v-else-if="!user">
  <p>You need to be signed in to view your account.</p>
  <button type="button" @click="showLogin">Sign in</button>
</div>
<div v-else>
  <nav><!-- Account, Products, Purchases, Redeem --></nav>
  <NuxtPage />
</div>

With Vue Router on its own, a guard can do the same before the page loads, waiting for the SDK to find out who is signed in:

router.beforeEach(async (to) => {
  if (!to.matched.some(route => route.meta.signedIn))
    return true

  await storefront.loadedUserPromise
  return storefront.currentUser.value ? true : { path: '/login', query: { redirect: to.fullPath } }
})

When you send a customer on after signing in, take only paths on your own site from redirect, so a link can't send them elsewhere.

Downloads and free trials

useInventory().getProduct(id) returns a product's current release with its files, and whether this visitor may download them: downloadsAllowed. When they may not, the downloadsNeeds… flags say why, so you can ask them to sign in, or to buy it. Corino's "Try HALO free" button opens ProductDownloadModal.vue, which does exactly that, and asks again when the visitor signs in:

import { useAuth, useInventory } from '@moonbase.sh/vue'

const { user } = useAuth()
const { getProduct, downloadProduct } = useInventory()

const product = ref(null)
watch([productId, () => user.value?.id], async ([id]) => {
  product.value = id ? await getProduct(id) : null
}, { immediate: true })

const blocked = computed(() => {
  const p = product.value
  if (!p || p.downloadsAllowed)
    return null
  if (p.downloadsNeedsUser && !user.value)
    return 'sign-in'
  if (p.downloadsNeedsOwnership)
    return 'purchase'
  return 'unavailable'
})

// When nothing blocks them, list product.downloads, and on a click:
await downloadProduct(download)

Each download has a platform (Mac, Windows, Linux or Universal), an arch and a size. Putting the files for the visitor's own computer first is up to you: Corino reads navigator.platform and marks that row.

The "Download product" links Moonbase sends open /download with a product_id. Corino sends a signed-in customer on to that product's page in their account, where the same downloads are, and asks anyone else to sign in.

Activating in the browser

When a plugin activates, it opens your "Activate in browser" page with a token. useActivationRequest follows that request: which product it is for, whether the customer has a license that can take another computer, and whether they can start a trial. It asks again when the customer signs in, and notices when the plugin has picked up the result:

import { useActivationRequest } from '@moonbase.sh/vue'

const {
  activationRequest, // .product, .licenseEligibility, .trialEligibility
  loading,
  error,
  fulfilling,
  isInstalled, // true once the plugin has the license
  fulfillLicense,
  fulfillTrial,
} = useActivationRequest(props.token)

Corino's activation/AutoActivation.vue activates on its own as soon as the signed-in customer has a license with a free seat, and otherwise offers what they can do: buy the product (looked up with useProduct(() => activationRequest.value?.product.id ?? '')), sign in to use a license, or start the free trial when trialEligibility.eligible, with the newsletter opt-in the trial may require:

watch(activationRequest, (request) => {
  if (!fulfilling.value && request?.status === ActivationRequestStatus.Requested && request.licenseEligibility?.eligible)
    fulfillLicense()
}, { deep: true })

The same page without a token is Corino's "Activate offline device" page. A plugin on a computer without internet saves a machine file, which activation/ManualActivation.vue sends with useInventory().activateProduct(machineFile, ActivationMethod.Offline). It returns the license, and a url for the license file to take back to that computer.

The account area

Corino's account area has four tabs, each a page under pages/account/ that renders one component from components/account/:

TabComponentCalls
AccountInfo.vueuseAuth().update(name, email, undefined, { newsletterOptIn, productUpdatesOptIn }), setPassword(current, new), signOut()
ProductsProducts.vue, ProductDetail.vueuseInventory().getProducts(), then for one product getProduct, downloadProduct, getProductActivations, revokeActivation and getProductLicenses
PurchasesPurchases.vueuseOrders().getOrders() and downloadReceipt(order)
RedeemRedeem.vueuseVoucher().redeem(code)

A changed email waits on a link to your "Confirm email change" page, which the result of update says as needsEmailConfirmationToken. useVoucher also has peek(code), to say what a voucher gives before redeeming it. Products list how many licenses the customer has, and the computers they're on as currentActivations of maxActivations. Revoking an activation frees up a seat for another computer.

useOrders lists what they've bought, newest first, a page at a time, and downloads each order's receipt:

import { formatMoney, useOrders } from '@moonbase.sh/vue'

const { getOrders, downloadReceipt } = useOrders()

const page = await getOrders()
// page.items: each order's items, total.due, completedAt and whether it was refunded
// page.next: pass it to getOrders() for the next page, until it is null

await downloadReceipt(order) // saves Receipt_<order id>.pdf

Lists from the SDK come a page at a time: pass a page's next back for the one after it, until it is null. Rely on next rather than on how many items came back, since a page of orders can come back short, or even empty, with more after it. Write an order's total with formatMoney(order.total.due).

If you sell subscriptions, useInventory() has getSubscriptions(), cancelSubscription(id), updateSubscriptionPaymentMethod(subscription) and renewSubscription(id), and milestoneProgress on a rent-to-own plan. Corino doesn't, so its "Manage subscription" page only loads the one in the link with getSubscription(id).

A newsletter sign-up

useCommunicationPreferences().subscribe signs anyone up, signed in or not. Corino's footer passes just the email:

import { useCommunicationPreferences } from '@moonbase.sh/vue'

const { subscribe } = useCommunicationPreferences()

const { status } = await subscribe({ email })
// 'subscribed', or 'confirmation_sent' when your store asks subscribers to confirm by email

Show both outcomes: with double opt-in on, nearly every sign-up ends in confirmation_sent. subscribe also takes a name, newsletter and productUpdates, and the IDs of customer lists to join in groupIds, ones you've opened to sign-ups in Moonbase.

The SDK picks up the UTM tags, referrer and ad click IDs a visitor arrives with, and sends them with the cart, checkout and sign-up, so a sale can be put down to the campaign that brought it (UTM tracking covers what you get from it). A click ID identifies the visitor's click, so the SDK keeps one only once the visitor has agreed to marketing cookies, and follows your store's "Ad and campaign tracking" setting until you tell it their answer. Corino asks with its own banner, CookieBanner.vue, and passes the answer on:

// The catalog the browser loads says whether this visitor has to be asked
storefront.onStorefrontUpdated((catalog) => {
  cookieConsent.state.value.required = catalog.cookieConsentRequired !== false
})

watch(() => cookieConsent.state.value.answer, (granted) => {
  if (granted !== null)
    storefront.setAttributionConsent(granted)
})

It remembers the answer in the browser and passes it to createStorefront as attributionConsent on the next visit. If your site already has a consent manager, call setAttributionConsent from its marketing-consent callback instead.

Going live

  • Add every address the site is served from, staging included, under Account settings > Storefront > "Domain configuration".
  • Set the storefront mode to Custom integration, with your pages, so emails and plugins link to your site.
  • Have your host answer every path, so a link to /activate or /forgot-password loads. Corino is generated with nuxt generate and served from GitHub Pages, where Nuxt's github_pages preset writes a 404.html that loads the app for any other path. For an app on createWebHistory(), answer every path with index.html.
  • If your site sets a Content-Security-Policy, allow https://<account-id>.moonbase.sh in frame-src for the checkout.
  • If you turn on Captcha under Account settings > Storefront > Features, signing up and newsletter sign-ups from your own forms are refused with CaptchaRequiredError, since the SDK has no captcha of its own to show. Customers can still create their account at checkout.

Was this page helpful?