@myelophone/nuxt
v0.20.1
Published
Production-oriented Nuxt 4 + Tailwind CSS application framework and Nuxt layer with SSR/SSG, multilingual routing, consent-aware integrations, Pinia stores, SEO, security headers, performance tooling, and reusable responsive components library.
Readme
@myelophone/nuxt
Production-oriented Nuxt 4 + Tailwind CSS application framework and Nuxt layer with SSR/SSG, multilingual routing, consent-aware integrations, Pinia stores, SEO, security headers, performance tooling, and reusable responsive components library.
[!TIP] Need a ready-to-run frontend instead of assembling a project from scratch?
@myelophone/nuxt-templateis the official frontend boilerplate built on@myelophone/nuxt. It provides a prepared application structure and starting point for quickly developing a new service or web site.Open template repository · Create a repository from this template
git clone https://github.com/myelophone/nuxt-template.git my-frontend cd my-frontend
What is @myelophone/nuxt
@myelophone/nuxt is an extendable Nuxt framework layer that supplies application shell behavior, modules, components, composables, stores, styles, middleware, server routes, and build optimizations.
This repository contains the framework source and its playground/ test application used exclusively while developing the layer, following the Nuxt convention for module/layer development. New applications should start from the separate @myelophone/nuxt-template, which consumes this layer and provides the application-facing project structure.
It is intended for content sites, landing pages, multilingual corporate sites, product interfaces, dashboards, catalogues, and commerce frontends that need a common production baseline instead of assembling the same infrastructure for every project.
The layer includes:
- Nuxt 4 and Vue 3 with TypeScript;
- Tailwind CSS 4 and a light/dark CSS-variable theme;
- SSR builds with Nitro's
node-clusterpreset by default, plus an explicit static generation mode; - localized routes and lazy namespace-based translations without external dependencies;
- SEO metadata, canonical URLs, alternate-language links, robots, OG images, breadcrumbs JSON-LD, and FAQ JSON-LD;
- cookie consent with necessary, analytics, marketing, and functional categories;
- presets for 30+ analytics, advertising, CRM, chat, and form providers;
- built-in cross-tab synchronization for theme and consent settings, optional user and cart stores, and selected fields from custom Pinia stores;
- currency conversion and an exchange-rate proxy;
- reusable UI, layout, media, navigation, and content components;
- CSP and security headers, request size limits, rate limiting, URL normalization, compression, asset caching, CSS cleanup, and build-time i18n tree shaking;
- reduced-motion, slow-network, low-battery, bot, and legacy-browser fallbacks;
- health endpoints, Playwright smoke testing, Biome, Commitlint, and semantic-release.
Requirements
- Node.js 24+ for local development. CI currently uses Node.js 26.
- Yarn 4.18.0 through Corepack.
- A modern browser for the complete interactive experience. Static and reduced-motion fallbacks cover constrained clients.
corepack enable
yarn install
yarn prepareQuick start
Start a new application from the template
Use the dedicated template repository for application development:
git clone https://github.com/MyelophOne/nuxt-template.git my-app
cd my-app
corepack enable
yarn install
yarn devThe template owns project-specific pages, components, assets, locales, and configuration while inheriting the framework from @myelophone/nuxt. Follow the template README for its exact file layout and bootstrap process.
Inside this framework repository, maintainers test layer behavior and configuration overrides through playground/myelophone.ts:
export default defineNuxtConfig({
app: {
head: {
title: "Acme",
},
},
multi18n: {
defaultLocale: "en",
locales: ["en", "pl", "de"],
},
runtimeConfig: {
apiBaseServer: "https://api.internal.example.com",
public: {
apiBase: "https://api.example.com",
},
},
myelophone: {
siteDomain: "https://example.com",
bundleTranslations: false,
splitCss: false,
stores: { cart: true, user: true },
frankfurterBaseCurrency: "EUR",
frankfurterCurrencies: ["USD", "PLN", "GBP"],
creativeCursor: false,
},
});playground/nuxt.config.ts deep-merges this file over the base layer. Objects are merged recursively and arrays are appended with duplicate primitive values removed. This playground exists only for framework development and testing; application scaffolding belongs to nuxt-template.
Extend it as a Nuxt layer
The layer is published on npm. Add it to the consuming application's package.json:
{
"devDependencies": {
"@myelophone/nuxt": "^0.19.1"
}
}Install dependencies from npm and prepare Nuxt types:
yarn install
yarn nuxt prepareThe dependency name from package.json is then used by extends:
// nuxt.config.ts
export default defineNuxtConfig({
extends: ["@myelophone/nuxt"],
multi18n: {
defaultLocale: "en",
locales: ["en", "pl"],
},
});Commands
| Command | Purpose |
| ------------------- | -------------------------------------------------------------- |
| yarn dev | Start the playground in development mode. |
| yarn build | Build the playground for SSR using the selected Nitro preset. |
| yarn server | Run the built Node server. |
| yarn generate | Generate a static site into playground/.output/public. |
| yarn preview | Preview a completed build. |
| yarn analyze | Analyze the Nuxt bundle. |
| yarn test:types | Type-check the layer and playground. |
| yarn playwright | Run browser tests; it builds and starts the app automatically. |
| yarn test | Run type checks, then Playwright tests. |
| yarn biome:check | Apply Biome lint fixes to app/ and playground/. |
| yarn biome:format | Format app/ and playground/. |
| yarn audit | Audit installed packages. |
| yarn upgrade | Upgrade Nuxt and regenerate prepared types. |
| yarn update | Interactively update dependencies. |
| yarn lock-update | Deduplicate the lockfile using the highest versions. |
The scripts use POSIX-style environment assignments and are expected to run in Linux, macOS, WSL, Git Bash, or CI. On native PowerShell, set the variables first or use a compatible shell.
Configuration reference
Environment variables
| Variable | Effect |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| NUXT_STATIC=true | Select the static Nitro preset, disable IPX, and copy the included .htaccess into static output. |
| NUXT_SSR_STREAMING=true | Enable experimental streaming SSR for non-static server builds; disabled by default. |
| NITRO_PRESET=<preset> | Override the SSR preset; defaults to node-cluster. |
| NUXT_PLAYGROUND=true | Include playground sources/locales while developing or building this repository. |
| PROD_DIST=true | Exclude playground-only locale globs from useMultiLang. |
| JSON_PLACEHOLDER_API_BASE_URL=<url> | Replace the default jsonPlaceholder endpoint used by nuxt-api-party. |
| NODE_ENV=<mode> | Select development, production, or test behavior. |
Nuxt runtime values can also be supplied with standard NUXT_* environment-variable mapping, for example NUXT_API_BASE_SERVER and NUXT_PUBLIC_API_BASE.
Runtime configuration
| Key | Default | Purpose |
| ---------------- | ------- | -------------------------------------------------------- |
| apiBaseServer | unset | Private server-side base URL used by useApi. |
| public.apiBase | unset | Public API base fallback used by useApi on the server. |
Myelophone build-time configuration
Project-level framework options belong in the top-level myelophone section of nuxt.config.ts or playground/myelophone.ts. These values are resolved during Nuxt setup/build instead of being serialized through runtimeConfig.public in the HTML payload. The layer supplies defaults, and local Nuxt config files can override them through normal Nuxt config merging.
| Key | Default | Purpose |
| ------------------------------------ | ----------------------- | ---------------------------------------------------------------------------------- |
| myelophone.siteDomain | http://localhost:3000 | Absolute production origin for SEO URLs and /sitemap.xml. |
| myelophone.blog.blogEnabled | true | Enables Markdown post routes, prerendering, and sitemap entries. |
| myelophone.blog.postsLayout | default-blog | Nuxt layout used by Markdown post pages. |
| myelophone.cookieControl.enabled | true | Enables the consent banner and preferences flow. |
| myelophone.cookieScripts | empty categories | Consent-aware integrations and legal notices. |
| myelophone.bundleTranslations | true | Bundle locale data together; false enables per-locale chunk grouping. |
| myelophone.splitCss | false | Controls Vite CSS code splitting. |
| myelophone.stores.cart | false | Initialize the cart store, exchange rates, persistence, and tab sync. |
| myelophone.stores.user | false | Initialize the user store, session extension, persistence, and tab sync. |
| myelophone.frankfurterCurrencies | [] | Default quote currencies for /api/exchange-rates. Empty means provider defaults. |
| myelophone.frankfurterBaseCurrency | USD | Base currency for the exchange-rate endpoint. |
| myelophone.creativeCursor | false | Enable the custom cursor for fine pointers without reduced motion. |
| myelophone.siteSearch | built-in defaults | Site-search strategy, query params, limits, and enablement. |
| myelophone.pageFullscreenPreloader | {} / disabled | Optional global full-screen route preloader component and overlay configuration. |
| myelophone.tally.domain | tally.so | Default host used by ViewTallyForm. |
| myelophone.noindex | false | Hide whole site from search engines. |
| myelophone.ssrStream | false | Enable experimental streaming SSR when not building static output. |
Rendering and deployment
SSR / Node cluster
yarn build
yarn server[!IMPORTANT] A regular production build uses Nitro's
node-clusterpreset by default. Runningyarn buildwithoutNUXT_STATICorNITRO_PRESETtherefore produces a clustered Node.js server build.
The effective preset selection is:
process.env.NUXT_STATIC === "true"
? "static"
: process.env.NITRO_PRESET || "node-cluster";Start the generated cluster build with yarn server, which runs playground/.output/server/index.mjs in this repository. In an application based on @myelophone/nuxt-template, run its corresponding production server command.
The default cluster preset lets Nitro run the application through Node's cluster model. This is the ready-to-use default for a dedicated Node.js host or container where the application owns its process model. HTML responses are compressed with Brotli/Gzip, public assets are precompressed, and hashed /_nuxt/** assets receive a one-year immutable cache header.
No configuration is required to keep node-cluster. Set NITRO_PRESET only when the deployment platform expects another Nitro target. For example, use a single Node server when Kubernetes, Docker orchestration, systemd, PM2, or the hosting platform manages process replication externally:
NITRO_PRESET=node-server yarn buildNUXT_STATIC=true takes precedence over NITRO_PRESET and selects the static preset instead of a Node server build.
Streaming SSR
The layer can use Nuxt's experimental streaming renderer in non-static builds. Enable it in myelophone.ts:
export default defineNuxtConfig({
myelophone: {
ssrStream: true,
},
});The environment variable is also supported for CI or deployment-specific overrides:
NUXT_SSR_STREAMING=true yarn build
yarn serverOn native PowerShell:
$env:NUXT_SSR_STREAMING = "true"
yarn build
yarn serverStreaming SSR is disabled by default. The framework maps myelophone.ssrStream: true or NUXT_SSR_STREAMING=true to experimental.ssrStreaming.enabled. Static generation always keeps streaming disabled because prerendered routes are emitted as complete HTML files.
With streaming enabled, Nuxt sends the document shell first and streams the rendered route body afterwards. Server-rendered data is still part of the final HTML response; streaming changes when chunks are sent, not whether the route content is rendered on the server. Bots, prerendered routes, SPA routes, redirects, and routes with incompatible cache rules can automatically use buffered rendering.
Server data that must be present in page source
Await useFetch or useAsyncData during setup. Nuxt renders the resolved value into the HTML and serializes it in the hydration payload, so the browser does not repeat the initial request:
<script setup lang="ts">
interface Post {
id: number;
title: string;
body: string;
}
const { data: posts, error } = await useFetch<Post[]>(
"https://jsonplaceholder.typicode.com/posts",
{
key: "posts-stream",
query: { _limit: 6 },
server: true,
default: () => [],
},
);
</script>
<template>
<p v-if="error">Could not load posts.</p>
<article v-for="post in posts" v-else :key="post.id">
<h2>{{ post.title }}</h2>
<p>{{ post.body }}</p>
</article>
</template>Do not move SEO or no-JavaScript content into onMounted, and do not wrap it in ClientOnly: neither approach can place the fetched result in the server HTML. Prefer useFetch/useAsyncData over a bare universal $fetch, because the Nuxt composables transfer the resolved value through the payload and avoid a duplicate hydration request.
Deliberately client-only or heavy data
Use a lazy client component when its code and data are intentionally excluded from SSR. A .client.vue suffix prevents server rendering, Lazy keeps the component in a separate async chunk, and server: false documents that the request must run in the browser:
<template>
<ClientOnly>
<LazyExamplesHeavyPosts v-if="showHeavyComponent" />
<template #fallback>Posts are not part of the server HTML.</template>
</ClientOnly>
</template><!-- components/examples/HeavyPosts.client.vue -->
<script setup lang="ts">
const { data: posts, status } = useFetch(
"https://jsonplaceholder.typicode.com/posts",
{
key: "posts-client",
query: { _limit: 6 },
server: false,
lazy: true,
},
);
</script>In this pattern the request starts only after the application renders the lazy client component, and the returned posts are therefore absent from View Source. Combine the component with an explicit user action, visibility condition, or another application-specific trigger when its download should be deferred further.
Streaming compatibility requirements
- The server and the hydrating client must initially select the same elements, attributes, IDs, and text. Do not derive initial markup independently from
Date.now(),new Date(),Math.random(), browser timezone, viewport size, or browser-only storage. - Use
useState,useFetch, oruseAsyncDatato serialize request-specific SSR state. Use Vue'suseId()for stable component IDs. Browser APIs belong inonMountedor.client.vuecomponents. - HTTP status, redirects, response headers, and cookie writes must be decided before the first response chunk is committed. If the result of an awaited route/component query determines those values, use buffered SSR for that route:
export default defineNuxtConfig({
routeRules: {
"/account/**": { streaming: false },
"/products/**": { streaming: false }, // dynamic 404/redirect after lookup
},
});- Keep above-the-fold paint-critical styles in global CSS. Deeply nested async components with their own scoped styles can briefly render before their late stylesheet chunk arrives.
- Test a production
build+server, not onlynuxt dev. Verify the response status and headers, inspect View Source after the stream completes, and check the browser console for hydration warnings.
UiScheduledContent follows these rules. It computes the initial active/upcoming/hidden branch on the server, stores that exact branch in the Nuxt payload with an instance-specific useId() key, and keeps using it until mount. The live browser clock starts only after hydration, preventing streamed HTML from selecting a different slot because of timing or server/browser timezone differences.
Static generation
yarn generateStatic mode disables runtime image transformation and payload extraction, crawls links, and includes an Apache .htaccess with clean HTML routing, compression, security headers, and cache rules.
High-load topology
The repository provides an application-level baseline, not a complete distributed architecture. For high traffic, place the app behind a CDN/reverse proxy, terminate TLS there, cache immutable assets and suitable HTML/API responses, run multiple stateless app instances, centralize logs/metrics, and use external durable storage for shared state.
The configured rate limiter uses an in-memory LRU driver. Its quota is local to each process, worker, or replica; use a shared driver or enforce global limits at the gateway before treating it as a distributed abuse-control mechanism.
Health checks are available at:
GET /healthz→{ "status": "ok" };GET /api/healthz→ok.
Markdown blog posts
The layer includes an optional server-rendered Markdown blog. Add application posts to the root content/ directory. The layer can provide default posts from app/content/; an application post with the same relative path overrides the layer post.
Create a post
The file path determines the URL recursively:
| File | URL |
| ------------------------------ | ------------------------- |
| content/hello-world.md | /post/hello-world |
| content/guides/deployment.md | /post/guides/deployment |
For example:
---
title: "Hello world"
description: "A short description used by search engines and social previews."
image: "/images/posts/hello-world.jpg"
---
# Hello world
This is a **Markdown** post.title, description, and image are optional string frontmatter fields. They populate the page title, meta description, and Open Graph image. Raw HTML in Markdown is disabled; plain URLs are linkified and typographic substitutions are enabled.
Posts are rendered on the server. The resulting post fragment is safely minified before it is inserted into the page source: structural whitespace is removed while significant inline whitespace and the contents of <pre> blocks are preserved.
Adding, removing, or renaming a post requires a rebuild. During a build, post files are discovered once, nested directories are scanned recursively, and the result is reused by prerender and sitemap generation. Markdown modules are loaded lazily and each parsed post is cached for the lifetime of the server process.
Configure the blog
export default defineNuxtConfig({
myelophone: {
siteDomain: "https://example.com",
blog: {
blogEnabled: true,
postsLayout: "default-blog",
},
},
});blogEnabled: falseremoves the catch-all post page and excludes posts from prerendering and the sitemap. Set it before building because changing it after deployment cannot recreate a route omitted from the build.postsLayoutselects the Nuxt layout used for every post.siteDomainsupplies the absolute sitemap origin. When it is not configured, the sitemap uses the current request origin.
Every discovered post is added to /sitemap.xml and the Nitro prerender route list. A missing post returns a real HTTP 404 response. In a regular production build, the initial payload is embedded in the HTML while extracted _payload.json files remain available for client navigation without an unused preload. NUXT_STATIC=true disables payload extraction and prerenders the post pages as static HTML.
Styling and themes
Global styles are loaded by the base app.vue. Tailwind scans Vue files across the layer and consumer project. The theme is driven by data-theme="light|dark" and these CSS variables:
:root {
--ui-bg: #f7f7f7;
--ui-text: #1a1a1b;
--ui-border: #374151;
--loader-fill-color: #0082e6;
}Use the settings store to switch or synchronize themes:
<script setup lang="ts">
const settings = useSettingsStore();
</script>
<template>
<UiButton label="Toggle theme" @click="settings.toggleTheme()" />
</template>The choice is stored locally, synchronized across tabs, reflected in the browser theme color, and initialized before paint to reduce theme flashing.
Ready page components
A ready page component is a complete, reusable page composition built from the layer's grid, UI, view, media, and content components. It owns its default content, markup, and visual preset, so a Nuxt page can render a finished page with a single component:
<template>
<ReadyProductPage />
</template>Keep ready page components in app/components/ready/. Nuxt derives the component name from the directory and filename: components/ready/ProductPage.vue becomes <ReadyProductPage />.
Ready components should contain only page-specific concerns:
- the content contract and other page-specific TypeScript interfaces;
- complete default content for every supported locale;
- the composition of existing components;
- named slots that are useful for exceptional site-specific replacements;
- the page's default CSS variables and the selectors that consume them.
Reusable behavior belongs to the layer rather than an individual ready component:
| API | Responsibility |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| useReadyPage() | Resolves configured locales, applies fallbacks and overrides, stays reactive, and updates title, description, Open Graph title, and Open Graph description for the active language. |
| mergeReadyContent() | Deeply merges objects without mutating the defaults. Arrays are replaced as complete collections. |
| readyCssVariables() | Converts a typed appearance object into inheritable --ready-* CSS variables. |
| PageReadyLocalized | Renders the resolved language variant through UiLangVisible. |
Marketing and editorial content belongs in the ready component, not in locale JSON files. Keep JSON namespaces for shared interface or service strings used by independent components, such as email, telephone, consent, navigation controls, validation messages, and common actions.
Minimal ready component
The following is a complete minimal app/components/ready/ProductPage.vue. Its specific types stay in the SFC and can be imported by a consuming page when type annotations are useful.
<script lang="ts">
import type {
DeepPartial,
ReadyContentByLocale,
} from "../../composables/useReadyPage";
export interface ProductPageContent {
seo: {
title: string;
description: string;
ogTitle?: string;
ogDescription?: string;
};
brand: string;
hero: {
title: string;
description: string;
actionLabel: string;
actionTo: string;
};
}
export interface ProductPageAppearance {
background: string;
text: string;
primary: string;
primaryContrast: string;
buttonRadius: string;
}
export type ProductPageContentOverride = DeepPartial<ProductPageContent>;
export type ProductPageContentByLocale =
ReadyContentByLocale<ProductPageContent>;
export type ProductPageAppearanceOverride = DeepPartial<ProductPageAppearance>;
</script>
<script setup lang="ts">
import {
mergeReadyContent,
readyCssVariables,
useReadyPage,
} from "../../composables/useReadyPage";
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
content?: ProductPageContentOverride;
contentByLocale?: ProductPageContentByLocale;
appearance?: ProductPageAppearanceOverride;
}>(),
{
content: () => ({}),
contentByLocale: () => ({}),
appearance: () => ({}),
},
);
const english: ProductPageContent = {
seo: {
title: "Acme - a clearer way to work",
description: "A short description of the complete product page.",
},
brand: "Acme",
hero: {
title: "A complete page from one component",
description: "The component provides useful content and layout immediately.",
actionLabel: "Get started",
actionTo: "/contact",
},
};
const defaultContentByLocale: Record<string, ProductPageContent> = {
en: english,
pl: mergeReadyContent(english, {
seo: {
title: "Acme - prostszy sposób pracy",
description: "Krótki opis kompletnej strony produktu.",
},
hero: {
title: "Kompletna strona z jednego komponentu",
description: "Komponent od razu udostępnia treść i układ.",
actionLabel: "Zacznij",
},
}),
};
const defaultAppearance: ProductPageAppearance = {
background: "var(--ui-bg)",
text: "var(--ui-text)",
primary: "#526dff",
primaryContrast: "#ffffff",
buttonRadius: "0.75rem",
};
const appearance = computed(() =>
mergeReadyContent(defaultAppearance, props.appearance),
);
const rootStyle = computed(() => readyCssVariables(appearance.value));
const { resolvedContentByLocale } = useReadyPage({
contentByLocale: defaultContentByLocale,
content: () => props.content,
overridesByLocale: () => props.contentByLocale,
fallbackLocale: "en",
});
</script>
<template>
<PageReadyLocalized
v-slot="{ content: page }"
:content-by-locale="resolvedContentByLocale"
>
<section
v-bind="$attrs"
:style="[rootStyle, $attrs.style]"
class="ready-product-page"
>
<GridContainer size="boxed" class="py-20">
<p>{{ page.brand }}</p>
<UiHeading :level="1">{{ page.hero.title }}</UiHeading>
<p>{{ page.hero.description }}</p>
<UiButton
as="nuxt-link"
:to="page.hero.actionTo"
:label="page.hero.actionLabel"
class="ready-product-page__button"
/>
</GridContainer>
</section>
</PageReadyLocalized>
</template>
<style scoped>
.ready-product-page {
background: var(--ready-background);
color: var(--ready-text);
min-height: 100dvh;
}
.ready-product-page :deep(.ready-product-page__button) {
background: var(--ready-primary);
border-color: var(--ready-primary);
border-radius: var(--ready-button-radius);
color: var(--ready-primary-contrast);
}
</style>useReadyPage() reads the active language used by the built-in multi-language system. PageReadyLocalized delegates visible content to UiLangVisible; ready components should not manually inspect the route or duplicate locale-selection logic.
Each locale entry must describe a complete usable page after merging with its fallback. The example creates Polish content by deeply overriding the English fallback. Because arrays represent ordered page collections, mergeReadyContent() replaces an overridden array instead of merging items by index.
Use and customize a ready page
No props are required when the defaults are suitable. A concrete site can change any nested value without editing or copying the ready component:
<script setup lang="ts">
import type { ProductPageContentByLocale } from "~/components/ready/ProductPage.vue";
const contentByLocale: ProductPageContentByLocale = {
en: {
brand: "Example Cloud",
hero: {
title: "Infrastructure without busywork",
},
},
pl: {
brand: "Example Cloud",
hero: {
title: "Infrastruktura bez zbędnej pracy",
},
},
};
</script>
<template>
<ReadyProductPage
:content="{
hero: {
actionTo: 'mailto:[email protected]',
},
}"
:content-by-locale="contentByLocale"
:appearance="{
primary: '#7c3aed',
buttonRadius: '999px',
}"
class="site-product-page"
/>
</template>Overrides are applied in this order:
- the ready component's fallback content;
- the ready component's content for the active language;
- common site overrides from
content; - active-language site overrides from
contentByLocale.
Use content for values shared by every language, such as URLs, email addresses, feature flags, IDs, and asset paths. Use contentByLocale for language-dependent editorial content. Both objects are reactive, so replacing either prop updates the rendered page and its SEO metadata.
The appearance prop exposes the variables intentionally supported by that ready component. Normal root attributes are also forwarded. This permits site-specific classes, data-*, aria-*, and additional or emergency CSS-variable overrides:
<ReadyProductPage
data-campaign="autumn"
style="--ready-primary: #e11d48; --ready-button-radius: 0.4rem"
/>Define every documented appearance variable on the ready component's root and consume it inside the component with var(--ready-...). CSS custom properties inherit through nested Vue components, while :deep() lets scoped ready styles target the roots of reused UI components. Prefer appearance for the stable public styling API and direct style variables for exceptional additions.
Ready component checklist
When adding another complete page or large ready page fragment:
- Create a descriptive file under
app/components/ready/. - Keep its content and appearance interfaces in that
.vuefile; do not add ready-specific contracts to the globalapp/types/directory. - Provide a complete fallback locale and all useful built-in locale variants inside the component.
- Include
seo.titleandseo.description; add localizedogTitleandogDescriptiononly when they should differ. - Accept
content,contentByLocale, andappearanceas deep partial overrides. - Call
useReadyPage()instead of implementing merging, locale selection, fallback, or SEO again. - Render the result through
PageReadyLocalizedso visibility continues to useUiLangVisible. - Put visual defaults in a typed appearance object, convert it with
readyCssVariables(), and bind the result to the root element. - Forward
$attrsand merge$attrs.styleafter the default root variables. - Add named slots only where arbitrary site markup is genuinely useful; prefer typed content overrides for normal changes.
- Add a separate route under
playground/pages/for development. Do not replace framework or application pages merely to demonstrate the component. - Run type checking and a production build before considering the ready component complete.
Reveal animations
v-reveal uses IntersectionObserver and automatically disables motion for bots, slow connections, low battery, old browsers, and prefers-reduced-motion users.
<section v-reveal>Default slide reveal</section>
<section v-reveal:fade.fast>Fast fade</section>
<section
v-reveal:zoom.slow.repeat="150"
>Repeated zoom; 150 ms queue step</section>Supported style arguments include slide, fade, and zoom; modifiers are fast, slow, and repeat.
Internationalization
The built-in multi18n module provides localized routes, lazy namespace loading, interpolation, pluralization, compact numbers, locale-aware links and visibility, language switching, fallback translations, SEO integration, and build-time removal of unused keys. No separate i18n module is required.
Configuration
Configure the supported languages and the unprefixed default language:
export default defineNuxtConfig({
multi18n: {
defaultLocale: "en",
locales: ["en", "pl", "ru"],
},
});defaultLocale defaults to en; locales defaults to ['en']. Duplicate locale values are removed in public runtime configuration. Use lowercase two-letter locale codes such as en, pl, and ru for consistent route and SEO handling.
Localized routes
Every regular Nuxt page receives a route for each non-default locale:
| Source page | English, the default locale | Polish | Russian |
| ------------------ | --------------------------- | -------------------- | -------------------- |
| / | / | /pl | /ru |
| /about | /about | /pl/about | /ru/about |
| /products/[slug] | /products/:slug | /pl/products/:slug | /ru/products/:slug |
The default language never needs a URL prefix. A prefixed default-language URL such as /en/about is permanently redirected to /about.
Exclude a page from route localization when it must have only one URL:
<script setup lang="ts">
definePageMeta({
i18n: false,
});
</script>Catch-all pages such as [...all].vue are not duplicated. An explicitly defined localized path is also preserved instead of being generated a second time.
The active locale is resolved from the first URL segment during SSR and updated after client navigation. The framework synchronizes settingsStore.currentLocale and the document's <html lang> attribute with that route.
Translation files and namespaces
Translation files use namespace paths:
app/locales/en/common.json
app/locales/pl/common.json
app/locales/en/products.json
app/locales/pl/products.jsonThe filename is the namespace: products.json is loaded with useMultiLang('products'), and its keys are addressed as products.*. Namespace paths can also be nested, for example locales/en/account/profile.json → account/profile.*.
Translation files are discovered in both the framework and application locale directories. Keep application-specific translations in their own namespaces. Inside a loaded JSON file, nested objects and dotted keys are expanded and can be mixed:
{
"title": "Products",
"hello": "Hello, {name}!",
"filters.empty": "No matching products",
"cart": {
"title": "Your cart"
},
"items_zero": "No items",
"items_one": "{count} item",
"items_other": "{count} items"
}Loading and translating namespaces
<script setup lang="ts">
const { t, tn, loadPromise, refresh } = useMultiLang(["products"]);
// `loadPromise` is the reactive pending state returned by useAsyncData.
// Call refresh() when translation files must be reloaded explicitly.
</script>
<template>
<div :aria-busy="loadPromise">
<h1>{{ t("products.title") }}</h1>
<p>{{ t("products.hello", { name: "Ada" }) }}</p>
<p>{{ t("products.items", 0) }}</p>
<p>{{ t("products.items", 12) }}</p>
<p>{{ tn("products.items", 12500) }}</p>
</div>
</template>useMultiLang accepts one namespace or an array and returns:
| Member | Purpose |
| ----------------------------- | ------------------------------------------------------------ |
| t(key) | Translate a string |
| t(key, params) | Replace {placeholder} values |
| t(key, count) | Select a plural form and format {count} |
| t(key, params, count) | Combine interpolation and pluralization |
| t(key, params, count, rule) | Force a plural suffix such as few or many |
| tn(key, count, params?) | Use compact number formatting such as 12K for large counts |
| loadPromise | Reactive namespace-loading state |
| refresh() | Reload the selected namespaces through Nuxt async data |
Plural keys use the suffixes returned by Intl.PluralRules, such as _one, _few, _many, and _other. For zero, _zero is checked first. _other is the final plural fallback.
const { t, tn } = useMultiLang("products");
t("products.hello", { name: "Ada" });
t("products.items", 0); // products.items_zero
t("products.items", { category: "books" }, 3);
t("products.items", {}, 3, "few"); // explicitly use products.items_few
tn("products.items", 12_500); // compact, locale-aware {count}Missing active-locale translations fall back to the default locale when that namespace is available, and unresolved values return the full translation key. Interpolated numbers use locale-aware Intl.NumberFormat formatting.
Namespaces are loaded lazily with import.meta.glob, cached in shared Nuxt state, loaded again when the active locale changes, and SSR-serialized for hydration. Set myelophone.bundleTranslations: false to group translation assets into separate per-locale chunks; keep it true to use the normal bundle grouping.
Dynamic keys and tree shaking
Production builds scan .vue, .ts, .js, and .mjs sources for literal keys used by t(), tn(), and $t(). Unused JSON entries are removed, while all plural variants belonging to a used base key are retained.
Dynamic translation keys cannot always be found statically. Preserve them with either method:
// @i18n-keep products.dynamic_title
useSafeList(
{
titleKey: "products.dynamic_title",
emptyStateKeys: ["products.empty.search", "products.empty.category"],
sections: [{ headingKey: "products.sections.featured" }],
},
{ safelistPath: "i18n-safelist.json" },
);useSafeList recursively collects:
- direct translation-key strings;
- object properties whose names end in
Key, exceptloadKey; - arrays stored in properties whose names end in
Keys; - matching values inside nested arrays and objects.
It is intended for server/build-time code because it uses the Node filesystem. Without options it writes sorted keys to .nuxt/i18n-safelist.generated.json. To feed collected keys directly into the configured tree-shaker, set safelistPath: 'i18n-safelist.json' as above. The same root file can also be maintained manually:
["products.dynamic_title", "products.empty.search", "products.empty.category"]Literal calls, @i18n-keep comments, the configured root safelist, and keys registered by built-in framework configuration are combined during the build.
Locale-aware navigation and content
Language-aware components:
<UiLanguageSelect show-full />
<UiLangLink to="/pricing">Pricing</UiLangLink>
<UiLangVisible only="pl">Polish-only content</UiLangVisible>
<UiLangVisible :except="['pl', 'ru']">All other languages</UiLangVisible>UiLanguageSelect lists configured locales, keeps the current path, query, and hash when switching, removes the prefix for the default locale, and automatically opens upward or right-aligned when viewport space is limited. Use its slots and class props for a custom presentation:
<UiLanguageSelect
show-full
container-class="relative"
trigger-class="rounded-lg px-4 py-2"
dropdown-class="rounded-lg"
option-class="text-sm"
>
<template #trigger>
<span class="uppercase">Choose language</span>
</template>
</UiLanguageSelect>Full language names use keys such as common.en, common.pl, and common.ru. Add them to every configured locale's common.json when show-full is enabled.
UiLangLink accepts to, exact, and replace. It prefixes internal paths only when the active locale is not the default and avoids adding the same prefix twice:
<UiLangLink to="/products">Products</UiLangLink>
<UiLangLink to="/checkout" replace>Checkout</UiLangLink>UiLangVisible accepts a locale string or array through either only or except. If both are omitted, its slot is always rendered.
The settings store can also drive language changes directly:
<script setup lang="ts">
const settings = useSettingsStore();
const selectPolish = () => settings.setLocale("pl");
</script>
<template>
<UiButton @click="selectPolish">Polski</UiButton>
</template>setLocale() ignores unsupported locale values and navigates to the equivalent localized route while retaining the current query and hash.
i18n and SEO
For localized pages, the application shell updates <html lang>, creates alternate hreflang links and an x-default link, and keeps localized breadcrumb paths and labels in sync. Set myelophone.siteDomain to the canonical production origin so SSR can produce absolute URLs.
SEO and URL behavior
Page metadata
<script setup lang="ts">
const product = await fetchProduct();
useAppSeo({
title: () => product.name,
descriptionKey: "products.seo_description",
params: { name: computed(() => product.name) },
noIndex: !product.isPublished,
});
</script>useAppSeo sets title, Open Graph title, description, Open Graph description, robots, and the current breadcrumb title. A static titleKey can be used instead of a value/function.
Other built-in SEO behavior:
- canonical URLs preserve only the
pagequery parameter; - URL paths are normalized to lowercase, duplicate/trailing slashes are removed, and production traffic is redirected away from
wwwand HTTP; - alternate-language and
x-defaultlinks are generated for localized pages; - breadcrumb JSON-LD is generated from route metadata and translation keys;
UiAccordion faqemits FAQPage JSON-LD;SeoProfileInfo,SeoOrgInfo, andSeoBrandInfoemit reactive SSR JSON-LD records;SeoNoIndexmarks 4xx pages asnoindex, nofollow;SeoContentNoIndexwraps content indata-nosnippetand renders it client-side;nuxt-og-image, robots, link checking, and SEO utilities are included.
<ViewBreadcrumbs :max-chars="40" separator="›" />
<SeoContentNoIndex fallback-height="160px">
Private or volatile text
<template #placeholder><span aria-hidden="true" /></template>
</SeoContentNoIndex>Set myelophone.siteDomain in production. Without it, server-rendered absolute alternate and breadcrumb URLs do not have a reliable origin.
Profile, organization, and brand JSON-LD
SeoProfileInfo, SeoOrgInfo, and SeoBrandInfo are head-only components: they render no visible element and add an SSR-accessible <script type="application/ld+json"> record to <head>. Values remain reactive during client navigation. JSON is escaped so supplied text cannot prematurely close the script element.
Use SeoProfileInfo only when the page primarily describes one person or organization. Google requires mainEntity and a name or alternateName on that entity for ProfilePage eligibility. The shorthand entity props cover Google's common fields; entity accepts every additional Person or Organization property:
<SeoProfileInfo
id="https://example.com/team/ada#profile-page"
url="https://example.com/team/ada"
name="Ada Lovelace - profile"
date-created="2024-01-10T09:00:00Z"
date-modified="2026-08-16T12:00:00Z"
entity-id="https://example.com/team/ada#person"
entity-name="Ada Lovelace"
alternate-name="@ada"
entity-description="Lead platform engineer"
entity-image="https://example.com/images/ada.webp"
:same-as="['https://github.com/ada', 'https://www.linkedin.com/in/ada']"
:interaction-statistic="[
{
'@type': 'InteractionCounter',
interactionType: 'https://schema.org/FollowAction',
userInteractionCount: 1250,
},
]"
:entity="{
jobTitle: 'Lead platform engineer',
worksFor: { '@id': 'https://example.com/#organization' },
knowsAbout: ['Nuxt', 'Accessibility'],
}"
:data="{
isPartOf: { '@id': 'https://example.com/#website' },
}"
/>If a complete entity object already exists, pass it as main-entity; it replaces the generated entity:
<SeoProfileInfo
:main-entity="{
'@type': 'Organization',
'@id': 'https://example.com/#organization',
name: 'Example Studio',
url: 'https://example.com/',
}"
/>For a single JSON-LD script with an @graph, set entity-only to make the profile's root node a Person or Organization, then pass every related node through graph. This covers graph structures containing a person, company, nested brand, WebSite, FAQPage, and any other Schema.org records:
<script setup lang="ts">
const personId = "https://aleksivanov.me/#person";
const organizationId = "https://myeloph.one/#organization";
const relatedGraph = [
{
"@type": "Organization",
"@id": organizationId,
name: "MyelophOne",
url: "https://myeloph.one/",
legalName: "Aliaksandr Ivanou",
founder: { "@id": personId },
brand: {
"@type": "Brand",
name: "MyelophOne",
},
},
{
"@type": "WebSite",
"@id": "https://aleksivanov.me/#website",
name: "Aleks Ivanou",
url: "https://aleksivanov.me/",
publisher: { "@id": personId },
},
{
"@type": "FAQPage",
"@id": "https://aleksivanov.me/#faq",
mainEntity: [
{
"@type": "Question",
name: "Who is Aleks Ivanou?",
acceptedAnswer: {
"@type": "Answer",
text: "A full-stack developer and founder of MyelophOne.",
},
},
],
},
];
</script>
<template>
<SeoProfileInfo
entity-only
:id="personId"
entity-type="Person"
name="Aliaksandr Ivanou"
:alternate-name="['Aleks Ivanou', 'Alex Ivanou', '@aleksivanou']"
url="https://aleksivanov.me/"
image="https://aleksivanov.me/assets/img/portrait.jpg"
job-title="Full-stack developer"
email="mailto:[email protected]"
description="Full-stack developer and founder of MyelophOne."
:same-as="['https://github.com/aleksivanou']"
:owns="{ '@id': organizationId }"
:knows-about="['Nuxt.js', 'Vue.js', 'TypeScript', 'Go']"
:graph="relatedGraph"
/>
</template>The result is one script shaped as { "@context": "https://schema.org", "@graph": [...] }. The generated profile/entity is the first graph node, followed by the supplied nodes. The graph prop is also available on SeoOrgInfo and SeoBrandInfo, so any of the three record types can be the primary node. When graph is omitted, the component continues to emit its normal standalone JSON-LD record.
Use SeoOrgInfo once on the home page or a page that describes the organization. Prefer the most specific applicable Schema.org subtype through type, such as Corporation, OnlineStore, or a suitable LocalBusiness subtype:
<SeoOrgInfo
type="Corporation"
id="https://example.com/#organization"
name="Example Studio"
alternate-name="Example"
legal-name="Example Studio sp. z o.o."
url="https://example.com/"
description="Product design and engineering studio"
telephone="+48-12-345-67-89"
email="[email protected]"
:logo="{
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
width: 512,
height: 512,
}"
:address="{
'@type': 'PostalAddress',
streetAddress: '1 Example Street',
addressLocality: 'Warsaw',
postalCode: '00-001',
addressCountry: 'PL',
}"
:contact-point="{
'@type': 'ContactPoint',
contactType: 'customer support',
telephone: '+48-12-345-67-89',
availableLanguage: ['en', 'pl'],
}"
:same-as="[
'https://github.com/example',
'https://www.linkedin.com/company/example',
]"
:data="{
foundingDate: '2020-01-01',
knowsLanguage: ['en', 'pl'],
}"
/>SeoOrgInfo has direct props for identity, names, URLs, logo/images, contacts, addresses, founders, employees, organization relationships, brands, service areas, ratings/reviews, offers, merchant policies, shipping services, awards, expertise, and common company identifiers (taxID, vatID, duns, globalLocationNumber, iso6523Code, leiCode, and naics).
SeoBrandInfo supports the complete Brand-specific set plus the commonly useful inherited Thing properties:
<SeoBrandInfo
id="https://example.com/#brand"
name="Example"
alternate-name="Example Labs"
url="https://example.com/"
logo="https://example.com/brand.svg"
slogan="Build clearly"
:same-as="['https://www.wikidata.org/wiki/Q123']"
:owner="{ '@id': 'https://example.com/#organization' }"
:aggregate-rating="{
'@type': 'AggregateRating',
ratingValue: 4.9,
reviewCount: 86,
}"
/>Common control props:
| Prop | Default | Purpose |
| ----------- | ---------------------- | ----------------------------------------------------------------------------------- |
| enabled | true | Reactively add or remove this JSON-LD script |
| context | https://schema.org | JSON-LD @context |
| type | Component type | JSON-LD @type; accepts a string or multiple types |
| id | — | Stable entity/page @id, normally an absolute URL with an optional fragment |
| data | {} | Any additional or future Schema.org properties for the root record |
| graph | — | Wrap the generated record and additional nodes in one root @graph script |
| scriptKey | Generated per instance | Override Nuxt head deduplication key when the record needs a stable application key |
| scriptId | — | Optional HTML id on the generated script element |
data is merged first; explicit component props override properties with the same name. @context and @type always come from their corresponding props. For SeoProfileInfo, entity is merged the same way for the generated mainEntity, while mainEntity supplies a fully custom entity and takes precedence over all shorthand entity fields. Set entityOnly when the root record itself must be the generated Person or Organization; in this mode the page-level id, name, url, description, and image props also act as fallbacks for their entity equivalents. This pass-through design supports all Schema.org properties, nested nodes, arrays, @id references, multi-typed records, and future vocabulary additions without waiting for a component update.
Always use absolute crawlable URLs, include only information represented by the visible page, and omit unknown data instead of inventing it. Validate deployed output with Google's Rich Results Test and the Schema.org validator; valid markup improves machine understanding but does not guarantee a rich result.
Cookie consent and third-party scripts
The application shell automatically renders the banner and settings modal. Preferences are stored for one year in privacy-preferences; necessary cookies are always enabled. A full decline is remembered for 30 days before the banner is shown again.
Configure presets
import {
cookieScriptPresets,
mergeCookieScriptConfigs,
} from "#myelophone/app/constants/predefinedCookieScripts";
const cookieScripts = mergeCookieScriptConfigs(
cookieScriptPresets.googleTagManager({ containerId: "GTM-XXXX" }),
cookieScriptPresets.googleAnalytics4({ measurementId: "G-XXXX" }),
cookieScriptPresets.microsoftClarity({ projectId: "xxxx" }),
);
export default defineNuxtConfig({
myelophone: {
cookieScripts,
},
});Available presets and required identifiers:
| Group | Presets |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Google | googleTagManager(containerId), googleAnalytics4(measurementId), googleAds(conversionId), recaptcha(siteKey) |
| Advertising | metaPixel(pixelId), vkPixel(pixelId), myTargetTopMailRu(counterId), linkedInInsight(partnerId), tiktokPixel(pixelId), pinterestTag(tagId), xPixel(pixelId) |
| Analytics | yandexMetrica(counterId), baiduTongji(siteId), matomo(trackerUrl, siteId), plausible(domain), umami(websiteId, src), hotjar(siteId), microsoftClarity(projectId), adobeAnalytics(src) |
| CRM/marketing | hubspot(portalId), bitrix24(widgetId), amoCrm(widgetHash), salesforcePardot(src), mailchimp(src) |
| Chat | intercom(appId), zendeskChat(key), crispChat(websiteId), tawkTo(propertyId, widgetId), jivoSite(widgetId), liveChat(license), tidio(publicKey), chatwoot(websiteToken), chatra(chatraId) |
Every preset also accepts common overrides such as id, localized name/description, translation keys, legalBasis, loadKey, and Nuxt Scripts options. mergeCookieScriptConfigs combines categories and registers translation keys for build-time safelisting.
Umami supports legalBasis: 'cookieless'; in that mode it is listed as a legal notice rather than a consent-gated analytics script.
Define a custom integration
export default defineNuxtConfig({
myelophone: {
cookieScripts: {
analytics: [
{
id: "acme-analytics",
name: { en: "Acme Analytics", pl: "Analityka Acme" },
nameKey: "cookies.acmeAnalytics.name",
description: "Anonymous traffic measurement",
descriptionKey: "cookies.acmeAnalytics.description",
provider: "Acme",
legalBasis: "consent",
loadKey: "acme-analytics",
src: "https://cdn.example.com/analytics.js",
beforeLoad: "window.acmeQueue = window.acmeQueue || [];",
onConsentChange: "window.acmeConsent = context.categories;",
options: { scriptAttributes: { defer: true } },
},
],
},
},
});Add the referenced nameKey and descriptionKey to each locale's cookies.json. The loader deduplicates scripts by loadKey, runs one-time initializers, applies category-specific hooks, and exposes consent state to hook code as context.categories and context.isAllowed(category).
Gate embedded content
<CookieConsentWrapper category="functional" service-name="Support chat">
<SupportChat />
</CookieConsentWrapper>
<ConsentYoutube video-id="dQw4w9WgXcQ" />
<ConsentGoogleMap address="Warsaw, Poland" language="pl" region="pl" />
<CookiePrivacyPolicy title="Cookie policy" />Programmatic controls:
const {
cookiePreferences,
isBannerVisible,
acceptAll,
declineAll,
savePreferences,
checkConsent,
} = useCookieControl();
savePreferences({ analytics: true, marketing: false, functional: true });This is a technical consent mechanism, not legal advice. Validate categories, wording, retention, transfers, and lawful bases with counsel for each deployment.
API access
useApi returns typed get, post, put, and delete helpers. It retries requests three times, forwards auth_token as a Bearer token, and converts response failures into Nuxt errors.
interface Product {
id: number;
name: string;
}
const api = useApi();
const products = await api.get<Product[], { category?: string }>("/products", {
category: "audio",
});
const created = await api.post<Product, { name: string }>("/products", {
name: "Myelophone",
});Configure both apiBaseServer and public.apiBase for real deployments. The current client-side fallback is http://localhost:3000, so relying on the default outside local development is unsafe.
User store
Enable it with myelophone.stores.user: true. The store provides normalized profiles, roles/permissions, auth status, expiry checks, session extension, consent-aware persistence, and cross-tab synchronization.
const user = useUserStore();
user.configureUser({
storageKey: "user",
cookieMaxAge: 60 * 60 * 24 * 14,
persistSession: true,
sessionExtensionSeconds: 3600,
});
user.setAuthenticated({
profile: {
id: 42,
email: "[email protected]",
name: "Ada",
roles: ["admin"],
permissions: ["orders.read"],
},
session: {
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
provider: "api",
},
});
if (user.hasRole("admin") && user.hasPermission("orders.read")) {
// show authorized UI
}Connect renewal to a backend:
user.setSessionExtender(async ({ session }) => {
return await $fetch("/api/session/extend", {
method: "POST",
body: { expiresAt: session?.expiresAt },
});
});Main actions: initUser, configureUser, applyState, setAuthenticating, setRefreshing, setAuthenticated, setProfile, patchProfile, setSession, setSessionExtender, extendSession, hasRole, hasAnyRole, hasPermission, hasAnyPermission, setError, clearError, logout, and resetStorage.
The persisted profile/session is client-readable and must never be treated as authorization proof. Keep access/refresh tokens in secure HttpOnly cookies and enforce all authorization on the server.
Cart, totals, coupons, and currencies
Enable it with myelophone.stores.cart: true. The cart supports mixed item currencies, quantity management, coupons, custom metadata, exchange rates, calculation strategies/hooks, consent-aware persistence, and cross-tab synchronization.
const cart = useCartStore();
cart.configureCart({
baseCurrency: "EUR",
autoFetchRates: true,
rateEndpoint: "/api/exchange-rates",
taxPercentMetaKey: "vatPercent",
shippingAmountMetaKey: "shippingAmount",
shippingCurrencyMetaKey: "shippingCurrency",
});
cart.addItem({ id: "sku-1", name: "Headphones", price: 99, currency: "EUR" });
cart.incrementItem("sku-1");
await cart.patchMeta({
vatPercent: 23,
shippingAmount: 8,
shippingCurrency: "EUR",
});
cart.applyCoupon("WELCOME10", 10);
cart.setCurrency("PLN");
console.log(cart.totalItems, cart.convertedTotals, cart.grandTotal);Add asynchronous business rules without replacing the store:
const removeFreeShipping = cart.addStrategy("free-shipping", ({ totals }) => {
if (totals.subtotal - totals.discount >= 100) return { shipping: 0 };
});
const stopAudit = cart.onAfterCalculate(async ({ state, totals }) => {
console.debug("calculated", state.items.length, totals.total);
});
// later
cart.removeStrategy("free-shipping");
stopAudit();Main actions: initCart, configureCart, addItem, removeItem, updateQuantity, incrementItem, clearCart, applyCoupon, removeCoupon, setMeta, patchMeta, getMeta, setCurrency, convertAmount, setExchangeRate(s), fetchExchangeRates, addStrategy, removeStrategy, onBeforeCalculate, onAfterCalculate, applyState, and resetStorage.
GET /api/exchange-rates?base=EUR¤cies=USD,PLN validates three-letter currency codes and proxies Frankfurter with a 10-second timeout. Treat third-party rates as informational unless your business requirements explicitly accept that source and update cadence.
State, storage, device, and browser composables
Generic persistence
const preferences = useStorage(
"preferences",
{ density: "comfortable", dismissed: [] as string[] },
{
storage: "cookie",
fallbackStorage: "local",
expires: 90,
deep: true,
syncTabs: true,
canUseCookie: () => useCookieControl().checkConsent("functional"),
shouldPersist: (value) => value.dismissed.length > 0,
},
);
preferences.value.value.density = "compact";
preferences.refresh();
preferences.remove();useStorage supports local, session, and cookie backends, custom serializers, expiry/path, default writes, fallback storage, persistence predicates, storage events, and BroadcastChannel synchronization.
Cross-tab synchronization
Open tabs from the same origin stay consistent without polling or a page reload. Synchronization starts on the client after Nuxt is ready and uses BroadcastChannel; local-storage-backed values also react to the browser storage event.
The application shell synchronizes these fields automatically:
| State | Synchronized fields | Availability |
| ------------------ | -------------------------------------------------------------- | --------------------------------------- |
| Theme | settings.theme and the applied data-theme/theme class | Always enabled |
| Cookie preferences | settings.cookiePreferences, settings.isCookieBannerVisible | Always enabled |
| Cart | items, coupon, meta, currency | When myelophone.stores.cart is true |
| User | profile, session, status, error, lastAuthenticatedAt | When myelophone.stores.user is true |
For example, adding an item or applying a coupon in one tab updates the other open tabs. Changing the theme updates both the Pinia state and the rendered document theme. Logging in, refreshing a session, updating a profile, or logging out is reflected across tabs when the user store is enabled.
Cart exchange rates, calculation strategies, hooks, and store configuration are not broadcast. User-store configuration and the session-extender callback are also local to each tab. Only the fields listed above are synchronized.
Synchronize selected fields from another Pinia store
Cross-tab synchronization is built in and can be added to any Pinia store with one useStoreBroadcast call. Pass the store and list the state fields that should be shared; no custom BroadcastChannel, message protocol, watchers, or loop prevention is required.
useStoreBroadcast accepts a store, an explicit key allowlist, and an optional channel name:
const filters = useCatalogFiltersStore();
const stopSync = useStoreBroadcast(filters, {
keys: ["query", "sort", "selectedCategories"],
channel: "catalog-filters",
});
// Synchronization is now active for the component's lifetime.
// Cleanup is automatic; call stopSync() only to stop it earlier.To synchronize another field later, add its state key to the same allowlist:
useStoreBroadcast(filters, {
keys: ["query", "sort", "selectedCategories", "viewMode"],
});Without channel, the channel name is pinia:<store-id>. Nested changes are watched deeply. Incoming values are applied with $patch, and source identifiers prevent a received update from being broadcast back in a loop.
Broadcast values must be serializable state composed of strings, numbers, booleans, null, arrays, and plain objects. Dates are transmitted as ISO strings. Functions, symbols, unsupported values, and circular references are converted to null and should not be included in the key list.
Synchronize standalone persisted state
useStorage enables tab synchronization by default. Set syncTabs: false for state that must remain isolated:
const sharedFilters = useStorage(
"catalog-filters",
{ query: "", categor