Installation
There are three ways to load <mapvx-website>. They all lead to the same result: the code of main.js running as an ES module, styles.css loaded as a stylesheet, and one <mapvx-website> element in the page.
| Method | Use it for |
|---|---|
| CDN | Plain HTML pages, CMS templates and quick tests. |
| npm and a bundler | Client-rendered apps built with Vite or a similar tool. |
| Self-hosting | Serving the files from your own origin, including frameworks that serve static files from a public folder, such as Next.js and Angular. |
The examples use version 1.6.1, the current release. Pin the exact version you tested, both in URLs and in package.json.
Requirements
- MapVX credentials: an API key and an institution id issued by MapVX. The API key is required for the data, the map, search and routes. Without a matching institution id, the component falls back to the first configured city and shows no category tabs on first load. See
api-keyandinstitution-id. - Browser: custom elements, ES modules (
<script type="module">) and CSS container queries, which drive the responsive layout. - WebGL for the map (map view, place profiles and routes). The grid of place cards does not need it. Version 1.6.1 uses WebGL2 and falls back to WebGL 1; plan for WebGL2, because the map engine bundled in upcoming releases requires it.
- One element per page. Use a single
<mapvx-website>per page: all elements on a page share one application (router, loaded data, map and SDK) and interfere with each other. See Multiple instances. - Content Security Policy: if your site sends one, allow the sources listed in Security and privacy.
- Node.js, for npm installs only: the package declares
engines.nodeas^22.22.3 || >=24.15.0. It has no effect in the browser. On other Node versions npm prints anEBADENGINEwarning and pnpm anUnsupported enginewarning (both fail only withengine-strict), and Yarn 1 stops the install unless you pass--ignore-engines.
Package contents
@mapvx/web-components-ssr publishes these files:
| File | What it is |
|---|---|
dist/browser/main.js | The element: an ES module of about 1.8 MB (about 500 KB gzipped) that contains everything it runs, including its own copy of Angular and the map engine. When it runs, it registers <mapvx-website>. The package’s main field points to it. |
dist/browser/chunk-*.js | Code that main.js loads on demand, only for 3D maps (the 3D map stack). The component’s map is 2D in 1.6.1, so a page load does not request these files. The file names carry content hashes that change between releases, and every chunk imports ./main.js by that exact name. |
dist/browser/styles.css | The required global stylesheet (about 106 KB, 15 KB gzipped): design tokens, map, marker and icon styles. main.js does not inject it. |
dist/types/public-api.d.ts | TypeScript declarations, referenced by the package’s types field. |
LICENSE, README.md, package.json | Package metadata. |
The package does not include:
- Fonts or images. The component asks for the Open Sans font but does not load it; see Fonts. Place images come from MapVX data.
- A server build. The element renders only in the browser, and evaluating
main.jsin Node throws. See SSR and initial data. - A runtime JavaScript API.
main.jsexports only internal names used by its own chunks. The declarations are forimport typeonly; see Types only. - Typings for the element itself (JSX or
HTMLElementTagNameMap). See Typing the element. - Polyfills. None are included, and the page does not need zone.js.
- A separate map worker file. The map worker is embedded in
main.jsand started from ablob:URL, which a Content Security Policy must allow (CSP). - Source maps.
The package has no exports field, so deep imports such as @mapvx/web-components-ssr/dist/browser/styles.css resolve.
npm
npm install --save-exact @mapvx/web-components-ssr@1.6.1--save-exact records the exact version instead of a range, so every install gets the build you tested. Then either import the package through your bundler (Bundlers) or copy its files into your public folder (Self-hosting).
npm also installs the packages the component lists as dependencies (Angular 22 packages, Font Awesome, ngx-google-analytics, rxjs and tslib). The browser bundle does not use them: main.js already contains everything it runs.
To prepare the initial-data attribute on your server, install the helper package where your server code runs. See The helper.
npm install --save-exact @mapvx/web-components-ssr-helpers@0.1.1CDN
unpkg
<link
rel="stylesheet"
href="https://unpkg.com/@mapvx/web-components-ssr@1.6.1/dist/browser/styles.css"
/>
<script
type="module"
src="https://unpkg.com/@mapvx/web-components-ssr@1.6.1/dist/browser/main.js"
></script>
<style>
/* The element is inline by default: give it a block box */
mapvx-website {
display: block;
width: 100%;
}
</style>
<mapvx-website api-key="YOUR_API_KEY" institution-id="YOUR_INSTITUTION_ID"></mapvx-website>The URL patterns are https://unpkg.com/@mapvx/web-components-ssr@VERSION/dist/browser/FILE and https://cdn.jsdelivr.net/npm/@mapvx/web-components-ssr@VERSION/dist/browser/FILE.
type="module"is required.main.jsis an ES module. Loaded as a classic script it fails (in Chromium, withSyntaxError: Unexpected token 'export') and the element stays empty (details). Module scripts are deferred automatically, sodeferis not needed.styles.cssis required. Without it, colors, fonts, the map and the icons break. It is a global stylesheet that also applies to the rest of your page; see Global styles.- Pin an exact version. jsDelivr serves
@latestin place and lets browsers cache it for up to 7 days, so a visitor can keep an oldmain.jsafter a release has changed the chunk names. unpkg redirects@latestto the exact version. Do not use thedevorrctags in production; see Release channels. - Both CDNs send
Access-Control-Allow-Origin: *and a JavaScript content type, somain.jscan load its chunks from the same versioned URL. - With a Content Security Policy, allow the CDN host in
script-srcandstyle-src(Allowlist policy). - The
display: blockrule gives the element a width to lay itself out in; see Sizing.
Self-hosting
Serve the files from your own origin when you do not want a third-party CDN, or when your framework serves static files from a public folder.
Copy the whole folder
Copy dist/browser from the installed package into a directory named after the version:
// copy-mapvx.mjs: run after npm install, before building your site
import { cpSync, readFileSync, rmSync } from 'node:fs'
const pkgDir = 'node_modules/@mapvx/web-components-ssr'
const { version } = JSON.parse(readFileSync(`${pkgDir}/package.json`, 'utf8'))
const target = `public/mapvx-website/${version}`
// Remove older versions, then copy main.js, every chunk-*.js and styles.css with unchanged names
rmSync('public/mapvx-website', { recursive: true, force: true })
cpSync(`${pkgDir}/dist/browser`, target, { recursive: true })
console.log(`Copied @mapvx/web-components-ssr@${version} to ${target}`)Run it from your package.json scripts, for example:
{
"scripts": {
"copy-mapvx": "node copy-mapvx.mjs",
"predev": "npm run copy-mapvx",
"prebuild": "npm run copy-mapvx"
}
}Load the files
<link rel="stylesheet" href="/mapvx-website/1.6.1/styles.css" />
<script type="module" src="/mapvx-website/1.6.1/main.js"></script>Configure the responses
| Header | Value | When |
|---|---|---|
Content-Type for .js | text/javascript | Always. Browsers refuse module scripts served with a non-JavaScript type, such as text/html or text/plain. |
Cache-Control | public, max-age=31536000, immutable | Only on a versioned path, as above. |
Access-Control-Allow-Origin | * or your site’s origin | Only when the files are served from another origin. |
Keep the files exactly as published. Every chunk-*.js imports ./main.js by that name, and these rules are what keeps the bundle working when chunks are loaded. In 1.6.1, main.js loads chunks only for 3D maps, and the component’s map is 2D, so a page load does not request them and a broken rule can go unnoticed until something loads a chunk.
- Do not rename
main.js. With a renamed entry file (for examplebundle.js), the chunks’ imports of./main.jsfail with a 404 when they load, or load a second copy if the original is also there. See Chunk files return 404. - Keep every
chunk-*.jsnext tomain.js, and never mix files from two versions. A missing or mismatched chunk fails only when it is loaded. - Do not add a query string to bust caches (
main.js?v=2). The chunks import the plain./main.js, which is a different URL and therefore a different module, so the first chunk that loads runs the bundle a second time, and that copy fails to register the element (Element already defined). Use a versioned directory instead. - On upgrade, replace the whole folder.
Caching. main.js and styles.css keep the same names in every release, while the chunk names change. That is why the long cache above is safe only on a path that contains the version. If you serve the files from a fixed path, a long cache can pair a stale main.js with the files of another version: use a short cache or no-cache for main.js and styles.css there.
Content type. A server that answers a missing file with an HTML fallback page (typical for single-page apps) makes the browser reject the module; check the path if you see a MIME type error or Unexpected token '<' (details).
Another origin. Browsers fetch module scripts and their imports in CORS mode, so if main.js and the chunks live on a different origin than the page (your own CDN, for example), that origin must send Access-Control-Allow-Origin. No crossorigin attribute is needed. If you add crossorigin="use-credentials", the server must return your exact origin instead of *, plus Access-Control-Allow-Credentials: true. styles.css, loaded with a regular stylesheet link, needs no CORS headers.
Bundlers
// Entry file of a client-rendered app
import '@mapvx/web-components-ssr'
import '@mapvx/web-components-ssr/dist/browser/styles.css'- The bare import resolves through the package’s
mainfield todist/browser/main.js; importing@mapvx/web-components-ssr/dist/browser/main.jsis equivalent. - Verified with Vite 7 (build and browser run). Vite bundles the chunks again under its own file names, so the naming rules of Self-hosting do not apply to its output.
- Not verified: webpack, Turbopack and the Angular CLI. If your bundler has trouble with the package, self-host the files instead; they need no processing.
- Load one copy per page. Do not import the package and also add a script tag for a CDN or self-hosted copy. The second copy starts a second application and fails to register the element (Element already defined).
Client only. main.js uses browser APIs as soon as it is evaluated; in Node it throws ReferenceError: HTMLElement is not defined. Never import it at the top level of a module that also runs on the server: SSR, prerendering, or Next.js 'use client' components, which are rendered on the server too. In those setups, load it with a <script type="module"> tag or with a dynamic import in code that runs only in the browser:
// Only in browser-only code: useEffect, onMounted, onMount, a Nuxt .client plugin…
import('@mapvx/web-components-ssr')Waiting for the element
main.js starts its application first and registers <mapvx-website> a moment later, asynchronously. Elements already in the page are upgraded at that point. Most integrations do not need to wait for it:
- Attributes set before the upgrade are read when it happens.
- JavaScript properties set on the element before the upgrade are picked up too.
- Event listeners can be attached right away, because the events are plain DOM events dispatched on the same element. Attach them early: events are not replayed, and a deep link can emit
show-placeduring startup. See Listening to events.
Use customElements.whenDefined() when you need the upgraded element, for example to read a property’s current value or to hide a loading indicator of your own:
// In a module script, where top-level await is allowed
const el = document.querySelector('mapvx-website')
// No need to wait for these
el.setAttribute('default-to-map', 'true')
el.addEventListener('userAction', (event) => console.log(event.detail))
await customElements.whenDefined('mapvx-website')
console.log(el.rowsPerPage) // the component's current value- Do not poll with
setTimeoutorcustomElements.get():whenDefined()resolves as soon as the element is registered. - If
main.jsfails to load, or is loaded as a classic script, the promise never resolves. See Nothing renders. - Content you put inside
<mapvx-website>is removed when the element upgrades, so do not place fallback or SEO markup there.
React
React 18 and 19 render <mapvx-website> as a plain DOM element. Three rules apply:
- Pass kebab-case attributes with string values:
default-to-map="true", neverdefault-to-map={true}. React 19 renders{true}as an empty attribute, which the element reads as false, and drops{false}, so the default applies. React 18 turns booleans into strings, but string values work in both versions. - Do not use camelCase props such as
apiKeyordefaultToMap. React 19 sets them as properties only when the element is already defined at render time; otherwise they end up as lowercase attributes that the element ignores. - Listen to events through a ref with
addEventListener. React 18 has no event props for custom elements. React 19 does, but the prop must beonfollowed by the exact event name,onuserAction;onUserActionnever fires.
In a client-rendered app, import the package once in the entry file:
// main.tsx (for example, a Vite app)
import '@mapvx/web-components-ssr'
import '@mapvx/web-components-ssr/dist/browser/styles.css'// MapvxWebsite.tsx
import { useEffect, useRef } from 'react'
import type { UserAction } from '@mapvx/web-components-ssr'
export function MapvxWebsite() {
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const onUserAction = (event: Event) => {
const action = (event as CustomEvent<UserAction>).detail
console.log(action.type, action.data)
}
el.addEventListener('userAction', onUserAction)
return () => el.removeEventListener('userAction', onUserAction)
}, [])
return (
<mapvx-website
ref={ref}
api-key="YOUR_API_KEY"
institution-id="YOUR_INSTITUTION_ID"
default-to-map="true"
show-city-filter="false"
/>
)
}With React 19 only, the event prop is an alternative to the ref:
<mapvx-website
api-key="YOUR_API_KEY"
institution-id="YOUR_INSTITUTION_ID"
onuserAction={(event: CustomEvent<UserAction>) => console.log(event.detail)}
/>With server rendering, load the bundle as shown in Next.js.
TypeScript
The package ships no JSX typing for the element, so TSX reports mapvx-website as an unknown element. Add a declaration file that the include of your tsconfig.json covers. Do not give it the same name as a .ts or .tsx file in the same folder (for example mapvx-website.d.ts next to the mapvx-website.tsx component from Next.js): TypeScript then skips the .d.ts file without any warning (Typing the element). This one was type-checked with @types/react 19. Typing the booleans as the strings 'true' and 'false' also catches the React 19 boolean pitfall at compile time.
// types/mapvx-website-react.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react'
import type { UserAction } from '@mapvx/web-components-ssr'
type BoolAttr = 'true' | 'false' // React 19 turns true into "" and drops false: pass strings
export interface MapvxWebsiteAttributes
extends DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> {
'api-key'?: string
'institution-id'?: string
'initial-data'?: string
'default-to-map'?: BoolAttr
'show-category-filters'?: BoolAttr
'show-city-filter'?: BoolAttr
'inherit-font-family'?: BoolAttr
'hide-deals'?: BoolAttr
'show-deal-labels'?: BoolAttr
'card-width'?: number | string
'rows-per-page'?: number | string
/** React 19+: listens to the case-sensitive 'userAction' event */
onuserAction?: (event: CustomEvent<UserAction>) => void
/** React 19+: listens to the 'cardSelected' event */
oncardSelected?: (event: CustomEvent<string>) => void
}
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'mapvx-website': MapvxWebsiteAttributes
}
}
}For the element’s JavaScript properties (HTMLElementTagNameMap), see Typing the element.
Vue
Vue 3 must know that mapvx-website is a custom element. Without compilerOptions.isCustomElement, Vue warns Failed to resolve component: mapvx-website, and @userAction listeners in single-file components never fire.
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'mapvx-website',
},
},
}),
],
})// main.ts
import { createApp } from 'vue'
import '@mapvx/web-components-ssr'
import '@mapvx/web-components-ssr/dist/browser/styles.css'
import App from './App.vue'
createApp(App).mount('#app')<script setup lang="ts">
import type { UserAction } from '@mapvx/web-components-ssr'
function onUserAction(event: Event) {
const action = (event as CustomEvent<UserAction>).detail
console.log(action.type, action.data)
}
</script>
<template>
<mapvx-website
api-key="YOUR_API_KEY"
institution-id="YOUR_INSTITUTION_ID"
default-to-map="true"
show-city-filter="false"
@userAction="onUserAction"
/>
</template>- Static string attributes work, and so do bindings:
:default-to-map="true"sets the attribute to"true", and:show-city-filter="false"sets it to"false". - Once
isCustomElementis set,@userActionand@cardSelectedkeep the event name’s case. - This setup was verified with Vue 3.5. In other build setups, set the same option wherever the Vue template compiler is configured; see Using custom elements in Vue .
- For Nuxt, see SSR and initial data.
Angular
Copy the published files with an assets entry in angular.json and load them from index.html. This keeps the files exactly as published and, in Angular SSR apps, keeps main.js out of the server bundle. Importing the package from TypeScript through the Angular CLI has not been verified. The element carries its own Angular and runs next to your application’s; this setup was tested with an Angular 20 host app that uses zone.js.
Copy the files as assets
Add this entry to the assets array of your build target in angular.json, next to the entries you already have:
{
"glob": "**/*",
"input": "node_modules/@mapvx/web-components-ssr/dist/browser",
"output": "mapvx-website/1.6.1"
}Load them in index.html
<!-- src/index.html, inside <head>. On upgrade, change the version here and in angular.json. -->
<link rel="stylesheet" href="mapvx-website/1.6.1/styles.css" />
<script type="module" src="mapvx-website/1.6.1/main.js"></script>Use the element in a component
import { Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'
import type { UserAction } from '@mapvx/web-components-ssr'
@Component({
selector: 'app-mapvx',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<mapvx-website
[attr.api-key]="apiKey"
[attr.institution-id]="institutionId()"
[attr.default-to-map]="defaultToMap()"
[attr.show-city-filter]="false"
(userAction)="onUserAction($event)"
></mapvx-website>
`,
})
export class MapvxComponent {
readonly apiKey = 'YOUR_API_KEY'
readonly institutionId = signal('YOUR_INSTITUTION_ID')
readonly defaultToMap = signal(true)
onUserAction(event: Event) {
const action = (event as CustomEvent<UserAction>).detail
console.log(action.type, action.data)
}
}schemas: [CUSTOM_ELEMENTS_SCHEMA]is required on every component whose template uses the element. Without it, the build fails with'mapvx-website' is not a known element.- Use
[attr.*]bindings. Angular writes booleans as"true"and"false", which the element reads correctly. - Never bind an attribute to
nullorundefined: Angular then removes it, which turnsshow-category-filtersandshow-city-filteroff instead of restoring their defaults, and throws an error forinstitution-id. See Removing attributes. - The
(userAction)template binding listens on the element itself, with the exact event name. - If the browser receives HTML instead of
main.js(Unexpected token '<'or a MIME type error), the assets entry or the path inindex.htmlis wrong. SeeUnexpected token '<'. - For server-rendered Angular apps, see Angular SSR.
Next.js
With the App Router, serve the published files from public, load main.js with next/script, and render the element from a client component.
Copy the files into public
Use the copy script from Self-hosting. It writes public/mapvx-website/1.6.1/, which Next.js serves as /mapvx-website/1.6.1/, with every file name unchanged.
Import the stylesheet
// app/layout.tsx
import '@mapvx/web-components-ssr/dist/browser/styles.css'Create a client component
// app/directory/mapvx-website.tsx
'use client'
import Script from 'next/script'
import { useEffect, useRef } from 'react'
import type { UserAction } from '@mapvx/web-components-ssr'
export function MapvxWebsite() {
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const onUserAction = (event: Event) => {
const action = (event as CustomEvent<UserAction>).detail
console.log(action.type, action.data)
}
el.addEventListener('userAction', onUserAction)
return () => el.removeEventListener('userAction', onUserAction)
}, [])
return (
<>
<Script type="module" src="/mapvx-website/1.6.1/main.js" />
<mapvx-website
ref={ref}
api-key="YOUR_API_KEY"
institution-id="YOUR_INSTITUTION_ID"
default-to-map="true"
/>
</>
)
}Render it in a page
// app/directory/page.tsx
import { MapvxWebsite } from './mapvx-website'
export default function Page() {
return <MapvxWebsite />
}- Load
main.jsonly throughnext/script. Neverimport '@mapvx/web-components-ssr'at the top of a component file: Next.js renders client components on the server too, and the import throws there. - Keep the default
strategy(afterInteractive), which injects the script from the client after hydration. If the element upgrades before hydration, React 19 reportsHydration failed because the server rendered HTML didn't match the clientand re-creates the tree on the client. - Keep the file names. Some older examples copy
main.jsasbundle.js, which breaks the chunks’ imports of./main.jswhen they load; see Self-hosting. - The same rules as in React apply to attributes and events. For TypeScript, add the declaration file from TypeScript.
- To pass data prepared on the server through
initial-data, see Next.js in SSR and initial data.
Other frameworks
- Nuxt, SvelteKit and Angular SSR: see SSR and initial data, with recipes for Nuxt, SvelteKit and Angular SSR. The rule for any SSR framework is to load
main.jsonly in the browser (Client-only loading). - Other client-rendered frameworks: import the package through your bundler, or add the CDN or self-hosted tags to your HTML. Pass kebab-case attributes with string values, and attach listeners to the element itself, because its events do not bubble (Listening to events).
- Server-rendered HTML without a JavaScript framework (PHP, Rails, Django, CMS themes): the CDN or self-hosted snippet works as is.
Whatever the setup, use a single <mapvx-website> per page: several elements share one application and interfere with each other. See Multiple instances.