@creative-locator/headless
v2.23.0
Published
Framework-agnostic platform adapters for Creative Locator. Drop-in implementations of the @creative-locator/core ApiClient / I18n / Storage / ConfigSource interfaces for headless consumers (Astro, Next.js, Eleventy, vanilla HTML) that talk to any WordPres
Maintainers
Readme
@creative-locator/headless
Drop-in platform adapters for Creative Locator consumers running outside WordPress.
Provides concrete implementations of the four @creative-locator/core adapter interfaces so that any host environment — Astro, Next.js, Eleventy, vanilla HTML, Remix, SvelteKit, or anything else that can render Leaflet in a browser — can render a Creative Locator against a Creative Locator REST backend over HTTP.
What's included
| Adapter | Interface | Purpose |
| ------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HttpApiAdapter | ApiClient | Generic fetch-based client against {baseUrl}/dealers, /search, /geocode, /taxonomies, /analytics/*. Optional custom headers and fetch override. |
| IdentityI18nAdapter | I18n | Single-locale default. Returns source strings unchanged; implements sprintf for positional placeholders. |
| BrowserStorageAdapter | Storage | window.localStorage wrapper with graceful degradation for private browsing and SSR. |
| InMemoryStorageAdapter | Storage | Map-backed Storage for SSR and tests. |
| StaticConfigAdapter | ConfigSource | Takes a LocatorConfig at construction time. |
Install
pnpm add @creative-locator/core @creative-locator/headless leaflet leaflet.markerclusterQuick start
Recommended (post-v2.9.0)
One-call factory — sensible defaults and a single import:
import { createHeadlessLocator } from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
createHeadlessLocator({
container: '#my-locator',
apiUrl: 'https://example.com/wp-json/creative-locator/v1',
});That's the entire boot sequence on a vanilla HTML page. The factory:
- Resolves
containerfrom a CSS selector viadocument.querySelector(or accepts a pre-resolvedHTMLElement). - Constructs
HttpApiAdapter/IdentityI18nAdapter/BrowserStorageAdapter/StaticConfigAdapterwith sensible defaults. - Merges any partial
LocatorConfigyou pass over the bundleddefaultLocatorConfigso you only specify the fields you care about. - Throws a descriptive error when the selector doesn't match — silent-mount-into-null was the most common pre-#476 misconfiguration.
- Auto-detects SSR contexts and falls back to in-memory storage so server-side construction never blows up.
Custom config + auth headers
createHeadlessLocator({
container: document.getElementById('my-locator')!,
apiUrl: 'https://example.com/wp-json/creative-locator/v1',
apiHeaders: { Authorization: `Bearer ${myJwt}` },
config: {
layout: 'directory',
defaultRadius: 25,
maxResults: 100,
},
});Per-adapter override
When you need a custom ApiClient (e.g. with retry middleware) but want the factory's defaults for everything else:
import { createHeadlessLocator, HttpApiAdapter } from '@creative-locator/headless';
createHeadlessLocator({
container: '#my-locator',
apiUrl: 'https://example.com/wp-json/creative-locator/v1',
adapters: {
api: new MyRetryingApiClient(/* ... */),
},
});Advanced (full adapter control)
If you need fine-grained control over every adapter, the four constructors are still exported and the legacy four-adapter recipe still works:
import { createLocator, type PlatformAdapters, type LocatorConfig } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
const locatorConfig: LocatorConfig = {
// ... your locator configuration. See @creative-locator/core's LocatorConfig type.
};
const adapters: PlatformAdapters = {
api: new HttpApiAdapter({
baseUrl: 'https://example.com/wp-json/creative-locator/v1',
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter(locatorConfig),
};
const container = document.querySelector<HTMLElement>('#my-locator')!;
createLocator(container, { adapters });Framework recipes
Astro
Most common pattern: render the locator as an island in an .astro page.
---
// src/pages/locator.astro
import '@creative-locator/core/styles/style.scss';
---
<div id="my-locator"></div>
<script>
import { createLocator } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
createLocator(document.querySelector('#my-locator')!, {
adapters: {
api: new HttpApiAdapter({
baseUrl: import.meta.env.PUBLIC_LOCATOR_API_URL,
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig — see @creative-locator/core types */
}),
},
});
</script>The <script> block runs in the browser, so all adapters work as expected. Set PUBLIC_LOCATOR_API_URL in .env to your WordPress site's REST base.
Next.js (App Router, client component)
The locator must mount in a Client Component because Leaflet touches window.
// app/components/Locator.tsx
'use client';
import { useEffect, useRef } from 'react';
import { createLocator } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
export function Locator() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const handle = createLocator(ref.current, {
adapters: {
api: new HttpApiAdapter({ baseUrl: process.env.NEXT_PUBLIC_LOCATOR_API_URL! }),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig */
}),
},
});
return () => handle?.destroy?.();
}, []);
return <div ref={ref} />;
}Then load it. next/dynamic({ ssr: false }) is a build error inside a
Server Component (Next 15 App Router), so the dynamic() call must live in a
Client Component wrapper that the server page renders:
// app/components/LocatorClient.tsx
'use client';
import dynamic from 'next/dynamic';
export const Locator = dynamic(() => import('./Locator').then((m) => m.Locator), {
ssr: false,
});// app/locator/page.tsx — a Server Component can render the client wrapper.
import { Locator } from '@/components/LocatorClient';
export default function LocatorPage() {
return <Locator />;
}Using the
@creative-locator/reactadapter instead? Itsreact-serverentry renders an SSR-safe shell, so you can drop<DealerLocator />straight into a Server Component with nodynamic()wrapper at all.
SvelteKit (+page.svelte with onMount)
Same pattern as Next.js — defer to onMount so SSR doesn't try to touch window.
<!-- src/routes/locator/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { createLocator } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
let container: HTMLDivElement;
onMount(() => {
const handle = createLocator(container, {
adapters: {
api: new HttpApiAdapter({
baseUrl: import.meta.env.PUBLIC_LOCATOR_API_URL,
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig */
}),
},
});
return () => handle?.destroy?.();
});
</script>
<div bind:this={container}></div>Vanilla HTML
Smallest possible setup — no framework, just an ESM <script type="module">:
The package ships styles as SCSS (@creative-locator/core/styles/style.scss),
not compiled CSS. A no-build page compiles it once and self-hosts the result
(npx sass node_modules/@creative-locator/core/styles/style.scss style.css);
the framework templates import the .scss directly and let their bundler
compile it.
<!doctype html>
<link rel="stylesheet" href="/style.css" />
<div id="my-locator"></div>
<script type="module">
import { createLocator } from 'https://esm.sh/@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from 'https://esm.sh/@creative-locator/headless';
createLocator(document.querySelector('#my-locator'), {
adapters: {
api: new HttpApiAdapter({
baseUrl: 'https://your-site.com/wp-json/creative-locator/v1',
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig */
}),
},
});
</script>SSR / hydration notes
- All adapters are client-only by design (Leaflet requires
window/document). BrowserStorageAdaptergracefully no-ops whenwindow.localStorageis unavailable (private browsing, SSR contexts) — safe to instantiate at module top-level without anif (typeof window …)guard.- For SSR-rendered pages: defer
createLocator()(or the framework wrappers'<DealerLocator />) to a client-only mount (Astro<script>, Next.jsuseEffectin a Client Component, SvelteKitonMount, etc.).
Server-render the static shell with renderLocatorShell (post-v2.9.0)
SSR parity across frameworks (ADPT-11): a component-level server-rendered shell currently exists for React only —
@creative-locator/react's server entry renders the shell in RSC/SSR passes and hydrates on the client. The Vue, Svelte, and Solid components are client-mount-only; for shell-first paint on those frameworks, callrenderLocatorShellbelow directly from your framework's server path and mount the component into the same container (createHeadlessLocatordetects the pre-rendered shell and skips re-injection). Per-framework shell components may follow if consumer demand materializes — the manualrenderLocatorShellpath is the supported answer today.
For real SSR — emitting the locator's static HTML scaffolding from your server-render path so visitors see the shell before JS hydrates — use renderLocatorShell:
import { renderLocatorShell } from '@creative-locator/headless';
// Server-side: produce the shell as a string and emit it inside
// your response. The `instanceId` should match the container's id
// so the client picks up the same skip-link / list-panel ids.
const shell = renderLocatorShell({ instanceId: 'my-locator' });
res.send(`
<div id="my-locator" class="creative-locator crloc-dealer-locator">${shell}</div>
<script type="module">
import { createHeadlessLocator } from '@creative-locator/headless';
createHeadlessLocator({
container: '#my-locator',
apiUrl: '${apiUrl}',
});
</script>
`);createHeadlessLocator detects the pre-rendered shell on the client and skips re-injection — the server-rendered DOM is preserved through hydration. The empty-container case still works for client-only mounts: when no shell is present, createHeadlessLocator calls renderLocatorShell internally before passing the container to createLocator.
Pass labels for translations, and mapPanelInert: true when the map should be decorative and non-interactive (e.g. an embed where the list is the sole interactive path). The default is false — the map stays keyboard-reachable:
renderLocatorShell({
instanceId: 'my-locator',
mapPanelInert: true,
labels: {
searchRegion: 'Buscar ubicaciones',
resultsRegion: 'Resultados',
skipToResults: 'Ir a resultados',
skipFiltersToResults: 'Saltar filtros e ir a resultados',
mapDescription: 'Mapa interactivo de ubicaciones.',
},
});The shell is the single source of truth shared with WordPress's server-side block render — see packages/wordpress/includes/Blocks/DealerLocatorBlock.php::render_shell() for the cross-language counterpart.
TypeScript
All adapters ship with full .d.ts definitions. The HttpApiAdapter constructor takes a typed HttpApiAdapterOptions:
interface HttpApiAdapterOptions {
baseUrl: string;
headers?: Record<string, string>;
fetchImpl?: typeof fetch;
}Override fetchImpl for testing or for environments where the global fetch needs middleware (auth tokens, request logging, retry). Note the createHeadlessLocator() factory exposes this as its fetch option and forwards it to the adapter's fetchImpl — so at the factory level you pass fetch, and when constructing HttpApiAdapter directly you pass fetchImpl.
License
GPL-2.0-or-later.
