@happydesigns/nuxt-variants
v0.2.0
Published
Composable page capabilities for Nuxt with typed config inheritance and Nuxt Content schema merging.
Maintainers
Readme
Nuxt Variants
Nuxt Variants is a small Nuxt module for building one shared layout that can behave differently per page. Define reusable feature configs, compose them into named page variants, and resolve the merged result in your layout with useVariant.
The package name is @happydesigns/nuxt-variants.
Why Use It?
Nuxt lets a page select a layout, but it does not describe which capabilities a page type needs inside that layout. Consider a content site with articles, events, and regular pages:
- all three use the same content shell;
- articles and events show a header, table of contents, copy action, and previous/next navigation;
- articles have authors, while events have a location;
- a short contact or overview page can use the same layout without a table of contents.
Without a shared model, this usually becomes duplicated layouts, checks such as
collection === "article", and separately maintained Nuxt Content schemas.
Nuxt Variants lets the app name small capabilities such as header, toc,
authors, location, copyButton, and surround, then compose them into
article, event, and content variants.
This is useful when several page or collection types share a layout but not all of its behavior. A small app with one layout and a few static props usually does not need a variant graph.
Nuxt Variants separates structural decisions from configurable values:
nuxt.config.tsdefines every name and inheritance edge, plus optional code-owned defaults.app.config.tssupplies layer or application defaults and runtime overrides for registered names.- Pages select a variant with
definePageMeta. - Layouts call
useVariantand render from the resolved config. - Nuxt Content can merge schemas from the same variant inheritance graph.
Features
- Flat variant registry for both reusable features and page variants.
- Deep object merge with array replacement, not array concatenation.
extendsinheritance across direct and transitive parents.- Reactive
app.configoverrides with generated types from both config sources. - Auto-generated TypeScript types through
#nuxt-variants. - Build-time virtual graph through
#variants-graph. - Fail-fast diagnostics for unknown parents, inheritance cycles, and invalid runtime structure.
- Nuxt DevTools inspector with filtering, resolution order, layer provenance, and resolved output.
- Graph-aware Nuxt Content schema helper through
@happydesigns/nuxt-variants/schemas.
Quick Setup
Use Node.js 22.19 or newer on the 22.x release line, Node.js 24.11 or newer on the 24.x release line, or Node.js 26+.
npx nuxt module add @happydesigns/nuxt-variantsManual install:
pnpm:
pnpm add @happydesigns/nuxt-variantsnpm:
npm install @happydesigns/nuxt-variantsyarn:
yarn add @happydesigns/nuxt-variantsbun:
bun add @happydesigns/nuxt-variantsThen register the module:
export default defineNuxtConfig({
modules: ["@happydesigns/nuxt-variants"],
});Basic Usage
1. Define Variants
export default defineNuxtConfig({
modules: ["@happydesigns/nuxt-variants"],
variants: {
registry: {
dates: {},
authors: {},
location: {},
header: {},
toc: {},
copyButton: {},
surround: {},
article: {
extends: ["dates", "authors", "header", "toc", "copyButton", "surround"],
config: {},
},
event: {
extends: ["dates", "location", "header", "toc", "copyButton", "surround"],
config: {},
},
content: ["header", "toc"],
},
},
});2. Override At Runtime
export default defineAppConfig({
variants: {
copyButton: {
config: {
copyButton: {
label: "Copy URL",
successLabel: "Link copied",
},
},
},
},
});app.config.ts wins over nuxt.config.ts for the same registered variant. Generated config types include both the registry and Nuxt's merged AppConfig, so an entry may be structural in the registry while its complete value contract lives in a layer or application app config. Names and extends remain in the registry; changing structure at runtime would make generated types, Content schemas, and rendering disagree, so Nuxt Variants rejects it during startup.
Current Nuxt Studio versions edit Nuxt Content files rather than app.config.ts directly. For owner-editable settings, define a small app-owned data collection with an explicit schema and map its values to updateAppConfig. This preserves the reactive variant API without exposing the technical variant graph to editors. See the documentation example for the complete pattern.
3. Select A Variant Per Page
definePageMeta({
layout: "content",
variant: "article",
});4. Resolve The Variant In A Layout
This mirrors the shared content layout in @happydesigns/ui. Nuxt Variants
provides useVariant; the rendered Nuxt UI and H* components remain owned by
the application or UI layer.
<template>
<UPage>
<UPageHeader v-if="hasHeader" />
<UPageBody>
<slot />
<HCopyButton v-if="hasCopyButton" v-bind="config.copyButton" />
<HSurround v-if="hasSurround" />
</UPageBody>
<template v-if="hasToc" #right>
<UContentToc />
</template>
</UPage>
</template>
<script setup lang="ts">
const route = useRoute();
const variantName = computed(() => route.meta.variant ?? "article");
const { config, has } = useVariant(variantName);
const hasHeader = has("header");
const hasToc = has("toc");
const hasCopyButton = has("copyButton");
const hasSurround = has("surround");
</script>When the variant name is a literal, config is typed from the build-time
registry and Nuxt's merged AppConfig:
const { config, has } = useVariant("article");
config.value.copyButton;
has("authors").value; // true
has("location").value; // falseNuxt Content
Nuxt Variants ships mergeVariantSchemas for Nuxt Content v3. It walks the variant graph and produces one Zod or Valibot object schema with inherited fields included.
Zod and Valibot are optional peer dependencies. Install the validator used by your Content schema; the other validator is not loaded or required.
Keep the registry in a normal TypeScript file when both Nuxt and Nuxt Content need it. This avoids module-order and virtual-alias coupling.
// variants.ts
import { defineVariantRegistry } from "@happydesigns/nuxt-variants/schemas";
export const variantRegistry = defineVariantRegistry({
dates: {},
authors: {},
header: {},
toc: {},
article: { extends: ["dates", "authors", "header", "toc"] },
});// nuxt.config.ts
import { variantRegistry } from "./variants";
export default defineNuxtConfig({
modules: ["@happydesigns/nuxt-variants", "@nuxt/content"],
variants: { registry: variantRegistry },
});// content.config.ts
import { defineCollection, property } from "@nuxt/content";
import { z } from "zod";
import { createVariantSchemaResolver } from "@happydesigns/nuxt-variants/schemas";
import { variantRegistry } from "./variants";
const variantSchemas = {
dates: z.object({ date: z.date().optional() }),
authors: z.object({ authors: z.array(z.string()).optional() }),
header: z.object({
header: property(z.object({})).inherit("@nuxt/ui/components/PageHeader.vue").optional(),
}),
toc: z.object({ toc: z.boolean().default(true) }),
};
const resolveVariantSchema = createVariantSchemaResolver(variantRegistry, variantSchemas);
export const collections = {
blog: defineCollection({
type: "page",
source: "blog/**",
schema: resolveVariantSchema(["article"]),
}),
};The resolver builds the explicit graph once from the shared registry and reuses it for every collection. Unknown active variants and schema registry keys throw immediately instead of producing an incomplete collection schema.
TypeScript
The module generates CustomVariantRegistry, VariantName, VariantNameInput, and VariantConfigOf in #nuxt-variants during Nuxt prepare. VariantNameInput retains suggestions for known names while accepting dynamic route or CMS values.
import type { VariantConfigOf } from "#nuxt-variants";
type ArticleConfig = VariantConfigOf<"article">;Generated config values are widened to primitive types. Config declared only
in app.config.ts is included automatically. If ordinary inference cannot
express a deliberately narrower or library-owned type, augment
CustomVariantOverrides in a module declaration:
import type { ButtonProps } from "@nuxt/ui";
export {};
declare module "#nuxt-variants" {
interface CustomVariantOverrides {
backButton: {
backButton: Pick<ButtonProps, "icon" | "label" | "to">;
};
}
}An override replaces the inferred config type for that registry entry and is
also applied when article or another variant inherits the entry. It changes TypeScript
types only; runtime values still come from nuxt.config.ts and
app.config.ts.
API Overview
useVariant(name) returns { config, features, has }.
configis aComputedRefof the fully merged config.featuresis aComputedRef<ReadonlySet<string>>resolved once per reactive change.has(featureName)returns aComputedRef<boolean>when the selected variant is that feature or inherits it directly or transitively.nameandfeatureNamecan be strings, refs, computed refs, or getters.active: falsedisables both resolved config andhas()checks for that variant.
useVariants() returns a computed list of known variants:
interface VariantEntry {
name: VariantName;
extends: VariantName[];
configKeys: string[];
}Virtual modules:
#nuxt-variantsexposes generated types.#variants-graphexposesvariantGraphandvariantDiagnostics.
Development tooling:
- The Nuxt DevTools tab named
Nuxt Variantsfollows the current route variant, filters by variant, parent, config key, or source layer, and shows resolution order, activity, layer provenance, raw inputs, and resolved config. Refreshing the inspector reads the currentapp.configoverrides again. - The backing inspector route is registered only in Nuxt dev and test environments.
Merge Rules
For a variant's own config, app.config.ts wins over nuxt.config.ts.
For every registry entry, app.config.ts overrides nuxt.config.ts. Across the
inheritance graph, parents are resolved first and the child overrides them. If
multiple parents define the same value, the later parent in extends wins.
Arrays are replaced:
base: {
config: { slots: ["header", "main"], color: "blue" },
},
article: {
extends: ["base"],
config: { slots: ["article"], density: "comfortable" },
},Resolving article produces:
{
slots: ["article"],
color: "blue",
density: "comfortable",
}Diagnostics
During Nuxt prepare, Nuxt Variants stops with one structured VariantRegistryError when the registry contract is invalid:
- variants extending unknown parent keys
- circular inheritance chains
app.configentries for unknown variantsapp.configentries that define structuralextends- unknown fields in registry entries or runtime overrides
- malformed entries or invalid
extends,active, andconfigvalue types
The error contains all detected diagnostics with stable codes, so one prepare run can identify every problem. Valid graph data remains available from #variants-graph and in the Nuxt DevTools inspector.
Registry entries accept only extends, active, and config. Runtime
app.config overrides accept only active and config; misspelled or extra
fields fail during startup instead of being silently ignored.
Playground And Documentation
The repository pins pnpm through packageManager, so Corepack and CI use the
same package manager version.
pnpm install
pnpm devThe playground demonstrates shared layouts, feature composition, runtime overrides, generated type contracts, and Nuxt Content schema merging.
pnpm docs:devThe Docus documentation source lives in docs/. See CONTRIBUTING.md for branch, commit, and PR rules.
Local Checks
pnpm dev:prepare
pnpm lint
pnpm typecheck
pnpm test
pnpm prepack
pnpm dev:build
pnpm docs:build