SSR and initial data
<mapvx-website> works in server-rendered sites (Next.js, Nuxt, SvelteKit, Angular SSR and
others), but it renders only in the browser. This page explains what the server can do for it,
the optional initial-data attribute with its helper package, and one recipe per framework.
In short: render the tag with its attributes on the server and load the bundle in the browser
only. initial-data is optional. It makes the first place cards appear before the API responds,
but it saves no API requests, and in 1.6.1, combined with api-key, it lists every place twice
(known issue). Without it, the component loads everything by
itself (client-only setup).
How it works
- The server outputs only the tag. Your framework renders
<mapvx-website>with its attributes and nothing inside it. Cards, profiles and the map are built in the browser, so the server HTML contains no place markup for crawlers, with or withoutinitial-data. If you need crawlable content, render it yourself outside the element. - The element replaces its content. When
main.jsdefines the element in the browser, the component builds its interface inside the tag and removes anything you put there, such as fallback text. - Never import the bundle in server code. Evaluating
main.jsin Node.js throwsReferenceError: HTMLElement is not defined. That includes top-level imports in components that are also rendered on the server, such as Next.js'use client'components. Load it with a<script type="module">or with a dynamicimport()that runs only in the browser. Type-only imports (import type) are safe anywhere, because they are removed at compile time. - With React, load it after hydration. With React 19, if the element upgrades before React
hydrates the page, React reports a recoverable hydration error and re-creates the tree on the
client (hydration mismatch). Loading the bundle after
hydration avoids it. The Next.js recipe below does this, and in SvelteKit
onMountalso runs after hydration. - Attributes are strings. The server can only pass attributes, so booleans must be the
strings
"true"or"false"(boolean attributes). - CSP nonce. If your policy uses a nonce for styles, render the
ngCspNonceattribute on the element from the server (Security and privacy).
Once the bundle runs in the browser:
- The element upgrades and reads its attributes. If
initial-datacontains places, their cards render right away. - With
api-keyset, the component starts the MapVX SDK and requests the available places, the configuration and the institutions, whether or notinitial-datais present. - The map is created the first time one is on screen (map view, a place profile or a route), once the configuration has arrived. Search, place details and routes also go through the SDK.
Versions before 1.5.4 could show a blank map on the first map-view load in server-rendered hosts (What’s new).
What initial-data changes
Without initial-data | With initial-data | |
|---|---|---|
| Server HTML | The empty tag with its attributes | The same, plus the data in one large attribute |
| First place cards | When the API responds | As soon as the element upgrades |
| API requests from the browser | Places, configuration, institutions | The same requests |
| Map, search, place details, routes | Need api-key | Need api-key |
| Place cards in the list | Listed once | Listed twice in 1.6.1 (known issue) |
initial-data is only a head start for the first cards: it doesn’t reduce API traffic and it
doesn’t replace api-key. Without api-key, the grid renders from initial-data, but the map,
search, place details and routes don’t work.
The attribute value
initial-datatakes a JSON string with two keys,availablePlacesandinstitutions(theInitialDatatype).prepareInitialDatareturns exactly that string. Other keys are ignored.- Invalid JSON is ignored without any console message in the published build, and the component loads normally from the API.
- If you set the
initialDataJS property instead, it must also be a string: an object throws aTypeErrorinside the component. - The category tabs come from the institution in the data whose id equals
institution-idexactly, so pass the id without surrounding spaces. - Every change to the value is parsed again and adds its places again (see the known issue). Set it once, on the first render.
- The category row has nothing on at the start either way, so the grid opens unfiltered whether
or not
initial-datais used. To open on specific categories, add?tab=to the page URL (deep links).
Attribute reference: Attributes.
The helper package
@mapvx/web-components-ssr-helpers
prepares the initial-data string on your server. Install it next to the component and pin the
exact versions you tested (0.1.1 is the helper’s current version):
npm install --save-exact @mapvx/web-components-ssr@1.6.1 @mapvx/web-components-ssr-helpers@0.1.1Its only export is prepareInitialData. The package ships ESM and CommonJS builds with TypeScript
declarations, plus an IIFE build for script tags (dist/helpers.iife.js, global Helper):
ESM
import { prepareInitialData } from '@mapvx/web-components-ssr-helpers'
const initialData = await prepareInitialData('YOUR_API_KEY')Requirements:
- A server runtime with a global
fetch: Node.js 18 or later, or an edge or worker runtime. The helper has no runtime dependencies and declares noengines. The component package does declareengines.node, which only affects installation (Requirements). - Outbound HTTPS access from your server to
https://api.mapvx.com. - Server-side use. The helper also works in a browser, but gains nothing there: the component requests the same data itself.
It has no options: the data is always in English, and there is no setting for the base URL, a timeout or an institution. It returns every place and institution available to the API key.
If you still use the legacy @mapvx/website-component-helpers package, switch to this one: the
legacy 0.1.1 throws instead of returning null when the API key is invalid or missing
(migration from 0.x).
prepareInitialData
function prepareInitialData(apiKey: string): Promise<string>It sends two parallel GET requests to the MapVX API at https://api.mapvx.com: the places
available to apiKey, and its institutions. Both carry the key and lang=en in the query string.
The key is public anyway: the browser receives it in the api-key attribute.
Return value
It resolves to a JSON string, not an object. Once parsed, the string has this shape:
// InitialData, exported as a type by @mapvx/web-components-ssr
interface InitialData {
availablePlaces: PlaceResponse[] | null
institutions: InstitutionResponse[] | null
}PlaceResponse and InstitutionResponse are the raw API objects. Their types are exported by
@mapvx/web-components-ssr for import type (TypeScript types); the
helper itself types the result only as string.
- Pass the string to
initial-dataunchanged. There’s no need to parse it and serialize it again. - It’s large. With an API key of 255 places and 4 institutions it measured about 557 KB, and about
830 KB once escaped into the HTML attribute (about 164 KB gzipped). Frameworks that also
serialize page data for hydration (the RSC payload in Next.js, the Nuxt payload, SvelteKit load
data, Angular
TransferState) send it a second time.
Errors
| Situation | Result |
|---|---|
| HTTP error from the API, for example 403 for an invalid or empty key | The helper logs Fetch failed with status ... with console.error and returns null for that half. With an invalid key the result is {"availablePlaces":null,"institutions":null}. |
| Network failure (DNS, refused connection, TLS) | The promise rejects. Both halves are lost and the helper logs nothing. |
| Successful response that isn’t JSON | The promise rejects with a SyntaxError. |
| The API doesn’t answer | The promise stays pending. There’s no timeout and no retry, so your server render waits with it. |
The component ignores a null half; with api-key set, it loads all its data from the API in any
case. To keep a failure or a slow API from breaking the render, wrap the call with a timeout and
fall back to no initial-data. The recipes below import this wrapper:
// mapvx-initial-data.ts
import { prepareInitialData } from '@mapvx/web-components-ssr-helpers'
// Resolves to the initial-data string, or undefined when it can't be prepared in time.
// With undefined, render the element without initial-data: it loads the data itself.
export async function getMapvxInitialData(
apiKey: string,
timeoutMs = 3000,
): Promise<string | undefined> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('MapVX initial data timed out')), timeoutMs)
})
try {
return await Promise.race([prepareInitialData(apiKey), timeout])
} catch {
return undefined
} finally {
clearTimeout(timer)
}
}The timeout only stops the wait: the requests aren’t cancelled and finish in the background.
Caching and freshness
- Each call makes two API requests and returns hundreds of KB; one measurement took about 1.5 s.
Avoid calling it on every request: cache the string on the server for a few minutes, for
example with your framework’s revalidation (
revalidatein Next.js). - If the page is prerendered at build time (Next.js without
revalidateor dynamic rendering, AngularRenderMode.Prerender, static exports), the data is fixed at build time. The component still loads current data in the browser and appends it, so places removed after the build keep showing next to the current ones.
Known issue: duplicated place cards
Every place card appears twice when initial-data and api-key are both set. The component
shows the places from initial-data, then appends the places from its own API request without
removing duplicates. This affects the current release, 1.6.1. Map markers are not duplicated.
The list grows again each time initial-data or institution-id changes, and each time the
element is mounted again in the same page session, for example after a client-side navigation
back to the page.
If this matters for your site, omit initial-data: the component loads the same data by itself
(client-only setup).
Next.js (App Router)
A Server Component prepares the data, and a Client Component renders the element and loads the
bundle from public/ after hydration.
Install the packages
npm install --save-exact @mapvx/web-components-ssr@1.6.1 @mapvx/web-components-ssr-helpers@0.1.1Copy the bundle into public/
Copy the whole dist/browser folder and keep every file name: every chunk-*.js file imports
./main.js by that name, so a renamed main.js breaks them. The versioned folder keeps browser
caches from mixing files of two releases (self-hosting).
// scripts/copy-mapvx.mjs
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}`
rmSync('public/mapvx-website', { recursive: true, force: true })
// main.js, every chunk-*.js and styles.css, with their published names
cpSync(`${pkgDir}/dist/browser`, target, { recursive: true })
console.log(`Copied @mapvx/web-components-ssr@${version} to ${target}`)Run it before next dev and next build, for example from npm pre scripts, which npm runs
before npm run dev and npm run build. If your package manager doesn’t run pre scripts (Yarn 2
or later, for example), call the script at the start of your dev and build scripts instead.
Add public/mapvx-website/ to .gitignore if you don’t want to commit the copy:
{
"scripts": {
"predev": "node scripts/copy-mapvx.mjs",
"prebuild": "node scripts/copy-mapvx.mjs"
}
}Import the styles
// app/layout.tsx
import '@mapvx/web-components-ssr/dist/browser/styles.css'Give the element display: block as well (sizing).
Declare the element for TypeScript
The package ships no JSX typing for <mapvx-website> (element typing).
Booleans are typed as the strings 'true' and 'false' because React 19 renders {true} as an
empty attribute (read as false) and drops {false} (so the default applies).
// types/mapvx-website-react.d.ts (the default Next.js tsconfig includes it)
import type { DetailedHTMLProps, HTMLAttributes } from 'react'
import type { UserAction } from '@mapvx/web-components-ssr'
type BoolAttr = 'true' | 'false'
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
onuserAction?: (event: CustomEvent<UserAction>) => void
oncardSelected?: (event: CustomEvent<string>) => void
}
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'mapvx-website': MapvxWebsiteAttributes
}
}
}Create the Client Component
// app/mapvx-website.tsx
'use client'
import Script from 'next/script'
import type { UserAction } from '@mapvx/web-components-ssr'
// Must match the folder created by scripts/copy-mapvx.mjs
const MAPVX_VERSION = '1.6.1'
type Props = {
apiKey: string
institutionId: string
initialData?: string
}
export function MapvxWebsite({ apiKey, institutionId, initialData }: Props) {
return (
<>
{/* next/script loads it after hydration by default (afterInteractive) */}
<Script type="module" src={`/mapvx-website/${MAPVX_VERSION}/main.js`} />
<mapvx-website
api-key={apiKey}
institution-id={institutionId}
initial-data={initialData}
default-to-map="true"
onuserAction={(event: CustomEvent<UserAction>) => console.log(event.detail)}
/>
</>
)
}Don’t import '@mapvx/web-components-ssr' in this file (import type is fine): Client
Components are also rendered on the server, where the bundle throws. On React 19 the
onuserAction prop listens to the userAction event; onUserAction doesn’t
(events in frameworks).
Prepare the data in the page
# .env.local
MAPVX_API_KEY=YOUR_API_KEY
MAPVX_INSTITUTION_ID=YOUR_INSTITUTION_ID// app/page.tsx (Server Component)
import { getMapvxInitialData } from '../lib/mapvx-initial-data'
import { MapvxWebsite } from './mapvx-website'
// Regenerate at most every 5 minutes. Without it, Next.js prerenders the page once, at build time
export const revalidate = 300
export default async function Page() {
const apiKey = process.env.MAPVX_API_KEY ?? ''
const initialData = await getMapvxInitialData(apiKey)
return (
<MapvxWebsite
apiKey={apiKey}
institutionId={process.env.MAPVX_INSTITUTION_ID ?? ''}
initialData={initialData}
/>
)
}lib/mapvx-initial-data.ts is the wrapper with a timeout. The page is still
prerendered during next build and then regenerated in the background, so the environment
variables must also be set at build time, and the build calls the MapVX API: without network
access there, the prerendered page ships without initial-data until it’s regenerated. The key
reaches the browser anyway through the api-key attribute; reading it on the server just keeps
the configuration in one place.
Nuxt
A server route prepares the data, so the helper runs on the server both during SSR and during
client-side navigation. A .client plugin loads the bundle in the browser only.
The steps use APIs that Nuxt 3 and Nuxt 4 share. In a Nuxt 4 project with the default app/
directory, put pages/ and plugins/ inside app/; server/ stays at the project root.
Configure Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
css: ['@mapvx/web-components-ssr/dist/browser/styles.css'],
vue: {
compilerOptions: {
// Without this, Vue warns about an unknown component and @userAction listeners don't fire
isCustomElement: (tag) => tag === 'mapvx-website',
},
},
runtimeConfig: {
// The browser needs both values as attributes, so they are public.
// Set them with NUXT_PUBLIC_MAPVX_API_KEY and NUXT_PUBLIC_MAPVX_INSTITUTION_ID
public: {
mapvxApiKey: '',
mapvxInstitutionId: '',
},
},
})Use runtimeConfig instead of process.env in components: process.env is empty in the
browser, so the server and the client would render different attribute values.
Load the bundle in the browser only
// plugins/mapvx.client.ts (the .client suffix keeps it off the server)
export default defineNuxtPlugin(() => {
// Not awaited, so hydration doesn't wait for the download
import('@mapvx/web-components-ssr')
})Prepare the data in a server route
// server/api/mapvx-initial-data.get.ts
import { getMapvxInitialData } from '../utils/mapvx-initial-data'
export default defineEventHandler(async (event) => {
const { mapvxApiKey } = useRuntimeConfig(event).public
return { initialData: (await getMapvxInitialData(mapvxApiKey)) ?? null }
})server/utils/mapvx-initial-data.ts is the wrapper with a timeout.
The server route matters because useAsyncData also runs in the browser on client-side
navigation: calling the helper directly inside it would move the call to the browser. To cache
the result, Nitro’s defineCachedEventHandler accepts a maxAge.
Render the element
<!-- pages/index.vue -->
<script setup lang="ts">
const config = useRuntimeConfig()
const { data } = await useAsyncData('mapvx-initial-data', () => $fetch('/api/mapvx-initial-data'))
</script>
<template>
<mapvx-website
:api-key="config.public.mapvxApiKey"
:institution-id="config.public.mapvxInstitutionId"
:initial-data="data?.initialData ?? undefined"
default-to-map="true"
/>
</template>SvelteKit
A server load function prepares the data and passes it to the page, and the page imports the
bundle in onMount, which runs only in the browser.
Add the environment variables
# .env
MAPVX_API_KEY=YOUR_API_KEY
MAPVX_INSTITUTION_ID=YOUR_INSTITUTION_IDPrepare the data on the server
// src/routes/+page.server.ts
import { MAPVX_API_KEY, MAPVX_INSTITUTION_ID } from '$env/static/private'
import { getMapvxInitialData } from '$lib/server/mapvx-initial-data'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async () => ({
// The browser needs the key as the api-key attribute, so returning it is fine
apiKey: MAPVX_API_KEY,
institutionId: MAPVX_INSTITUTION_ID,
initialData: await getMapvxInitialData(MAPVX_API_KEY),
})src/lib/server/mapvx-initial-data.ts is the
wrapper with a timeout. $env/static/private inlines the values
at build time; use $env/dynamic/private to read them at runtime. Don’t read process.env in
+page.svelte: it doesn’t exist in the browser.
Render the element
<!-- src/routes/+page.svelte (Svelte 5) -->
<script lang="ts">
import { onMount } from 'svelte'
import '@mapvx/web-components-ssr/dist/browser/styles.css'
let { data } = $props()
// onMount runs only in the browser, after hydration
onMount(() => {
import('@mapvx/web-components-ssr')
})
</script>
<mapvx-website
api-key={data.apiKey}
institution-id={data.institutionId}
initial-data={data.initialData}
default-to-map="true"
></mapvx-website>On Svelte 4, declare the data with export let data instead of $props().
Angular SSR
The host app copies the bundle as an asset and loads it from index.html, so the server never
evaluates it. The element bundles its own Angular and runs independently of your app’s Angular
version and change detection setup (for example, an Angular 20 app with zone.js). The server-side
code below uses PendingTasks.run and the RenderMode server routes, which need Angular 19 or
later.
Copy the bundle as an asset
In angular.json, add this entry to assets in the build options:
{
"glob": "**/*",
"input": "node_modules/@mapvx/web-components-ssr/dist/browser",
"output": "mapvx-website/1.6.1"
}Keep the version in the output folder and change it when you upgrade: the server.ts generated
by the Angular CLI serves static files with a one-year cache, and main.js keeps its name across
releases.
Load it in index.html
<!-- src/index.html, inside <head> -->
<link rel="stylesheet" href="mapvx-website/1.6.1/styles.css" />
<script type="module" src="mapvx-website/1.6.1/main.js"></script>Don’t import @mapvx/web-components-ssr from your TypeScript code except with import type: that
code also runs on the server.
Render the element with the server data
// src/app/mapvx.component.ts
import { isPlatformServer } from '@angular/common'
import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
PLATFORM_ID,
PendingTasks,
TransferState,
inject,
makeStateKey,
signal,
} from '@angular/core'
import { getMapvxInitialData } from './mapvx-initial-data'
const MAPVX_INITIAL_DATA = makeStateKey<string>('mapvx-initial-data')
@Component({
selector: 'app-mapvx',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<mapvx-website
[attr.api-key]="apiKey"
[attr.institution-id]="institutionId"
[attr.initial-data]="initialData() || null"
default-to-map="true"
></mapvx-website>
`,
})
export class MapvxComponent {
readonly apiKey = 'YOUR_API_KEY'
// Never bind institution-id to null or undefined: removing it throws inside the component
readonly institutionId = 'YOUR_INSTITUTION_ID'
private readonly transferState = inject(TransferState)
// In the browser, this reads the string the server rendered with
readonly initialData = signal(this.transferState.get(MAPVX_INITIAL_DATA, ''))
constructor() {
if (isPlatformServer(inject(PLATFORM_ID)) && !this.initialData()) {
// The server waits for this task before it serializes the page
inject(PendingTasks).run(async () => {
const data = await getMapvxInitialData(this.apiKey)
if (data) {
this.transferState.set(MAPVX_INITIAL_DATA, data)
this.initialData.set(data)
}
})
}
}
}CUSTOM_ELEMENTS_SCHEMAis required: without it, the compiler rejects<mapvx-website>as an unknown element.[attr.*]bindings write booleans as"true"or"false", andnullremoves the attribute. That’s fine forinitial-data, but never bindinstitution-idtonullorundefined(removing attributes).PendingTasksmakes the server wait for the data before it serializes the page, andTransferStatehands the same string to the browser, so the attribute doesn’t change during hydration.mapvx-initial-data.tsis the wrapper with a timeout.
Render on the server for fresh data
// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
// RenderMode.Prerender would fix the data at build time
{ path: '**', renderMode: RenderMode.Server },
]With RenderMode.Server, every request calls the helper, so cache its result
(caching and freshness).
Client-only setup (no initial data)
Every recipe above also works without initial-data: remove the helper and the server-side data
code, and keep the browser-only loading. You don’t need @mapvx/web-components-ssr-helpers then.
The component requests its data in the browser, and the cards appear once, when the API responds.
| Framework | Load the bundle in the browser only |
|---|---|
| Any server-rendered page | <script type="module"> pointing at main.js (CDN or self-hosted) |
| Next.js | next/script with type="module" and the copy in public/ |
| Nuxt | A .client plugin that calls import('@mapvx/web-components-ssr') |
| SvelteKit | import('@mapvx/web-components-ssr') inside onMount |
| Angular SSR | <script type="module"> in src/index.html |
In Next.js, for example, a Server Component is enough. Keep the copy into public/, the styles
import and the TypeScript declaration from the Next.js recipe:
// app/page.tsx
import Script from 'next/script'
export default function Page() {
return (
<>
<Script type="module" src="/mapvx-website/1.6.1/main.js" />
<mapvx-website
api-key={process.env.MAPVX_API_KEY ?? ''}
institution-id={process.env.MAPVX_INSTITUTION_ID ?? ''}
default-to-map="true"
/>
</>
)
}