> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frontic.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Nuxt Module

The `@frontic/nuxt` module wires your Nuxt application to the Frontic backend. It ships composables for data fetching, automatic proxy configuration, and locale management.

## Installation

```bash npm theme={"theme":"css-variables"}
npx nuxi@latest module add @frontic/nuxt
```

```bash pnpm theme={"theme":"css-variables"}
pnpm dlx nuxi@latest module add @frontic/nuxt
```

```bash yarn theme={"theme":"css-variables"}
yarn dlx nuxi@latest module add @frontic/nuxt
```

This installs the module and adds it to your `nuxt.config.ts` automatically.

```ts nuxt.config.ts theme={"theme":"css-variables"}
export default defineNuxtConfig({
  modules: ["@frontic/nuxt"],
});
```

> [!TIP]
>
> The module works out of the box with sensible defaults: CORS proxy enabled,
> all composables registered, and TypeScript paths configured.

### Requirements

The module needs Nuxt 4 or later and Node.js 20.19 or later.

Its composables cache through [Pinia Colada](https://pinia-colada.esm.dev), so the module brings its own Pinia and registers `@pinia/nuxt` and `@pinia/colada-nuxt` unless your app already does. If your app defines its own Pinia stores, they run on the module's Pinia version - check Pinia's release notes when upgrading the module, and don't pin an older Pinia in your own `package.json`.

***

## Configuration

All options are optional. Customize behavior as needed:

```ts nuxt.config.ts theme={"theme":"css-variables"}
export default defineNuxtConfig({
  modules: ["@frontic/nuxt"],
  frontic: {
    // All options below are optional
    contextDomain: "demo-shop.com",
    proxy: "/api/custom-path",
    redirectOn301: false,
    throwOn404: false,
    // ... other options
  },
});
```

### Options

#### API

**proxy** `boolean | string` (default: true)

Enable the built-in CORS proxy at `/api/frontic`, or set a custom path.

**Options**

**true** `boolean`

Enable proxy at `/api/frontic`.

**false** `boolean`

Disable proxy (use for external proxy setups).

**string** `string`

Custom proxy path (e.g., `/api/custom-frontic`).

**proxyTimeout** `number` (default: 10000)

Milliseconds before the proxy gives up on the Fetch API. Set `0` to disable the timeout.

Without a bound, a stalled upstream holds the request until Node's default of 300 seconds - long after the page is lost, and long enough to exhaust your server's connection pool. A timeout returns `504 Gateway Timeout` and fires [`frontic:fetch:error`](#error-reporting).

**fetchApiSecret** `string`

The secret for a project whose Fetch API is protected. **Server-only** - kept in private runtime config, never exposed to the browser.

**When you need one**

A project's Fetch API answers anyone holding its URL until a secret is created for it, and creating one is itself the switch - there is no separate public/protected setting. Most projects never do; teams that do are protecting the data rather than an environment: a B2B or partner catalogue, pricing worth scraping, or a rule that nothing is readable without a credential. Protection belongs to the project, not to any one domain or storefront pointed at it.

The secret is project configuration, so [Release Control](https://docs.frontic.com/releases/what-flows-through.md) moves it forward like any other change - created on develop, propagated to preview and then public. A storefront pointed at public therefore keeps working until the release carrying the secret lands.

```ts theme={"theme":"css-variables"}
frontic: {
  fetchApiSecret: process.env.FRONTIC_FETCH_SECRET,
}
```

Set it once and no call site passes a secret. The proxy injects it into browser traffic; on the server, `useFronticClient` passes it and a Nitro plugin covers every other path - [`createFronticServerClient`](#server-routes), and a generated client called directly from server code. Nothing enforces that split - handing the value to the browser works, and makes the protection meaningless.

On a protected project, a missing or wrong secret is `401` on **every** call at once - never partial, and never the cause of empty data, a wrong locale or a stale cache.

#### Routing

**redirectOn301** `boolean` (default: true)

Automatically redirect on 301 responses in `useFronticPage`.

**throwOn404** `boolean` (default: true)

Throw a 404 error when page is not found in `useFronticPage`.

**sitemap** `boolean | \{ domains?: string[] \}` (default: false)

Serve the project's [sitemap](https://docs.frontic.com/reference/fetch-api/sitemap.md) from your app at `/sitemap.xml`, with `domains` as the served domains. See [Sitemap](#sitemap).

#### Context

**disableContext** `boolean` (default: false)

Disable automatic context fetching and cookie management in `useFronticContext`.

**When enabled**

* The context endpoint will **not** be called automatically on mount
* No context cookie will be set automatically
* You must manually call `refresh()` to fetch contexts
* You are responsible for storing and managing the context token

Use this when you want full control over context management, or when integrating with an existing locale/region system.

**contextCookieName** `string` (default: fs-context)

Cookie name for storing the context token.

**contextCookieMaxAge** `number` (default: 31536000)

Cookie max age in seconds. Default is 1 year.

**contextDomain** `string | boolean`

The value every composable sends as `contextDomain`, which Frontic matches against your [domain rows](https://docs.frontic.com/project-settings/domains.md) to resolve scope, region, and locale. It also forms the host portion of the slug `useFronticPage` looks up.

**Options**

**string** `string`

A static domain (`'demo-shop.com'`) used for every request.

**true** `boolean`

Read the domain from the active `@nuxtjs/i18n` locale, so it changes with the locale. Requires `@nuxtjs/i18n` - see [Per-locale domains](#per-locale-domains).

When not set, `useFronticPage` derives the domain from the request URL host (`useRequestURL().host`). This works in production where the host *is* the Frontic domain, but not when the host doesn't match - for example in **local development** (`localhost:3000`), **sandbox / preview environments**, or **staging** where only the production domain is configured in Frontic.

You can configure a **domain alias** in your [admin app](https://docs.frontic.com/project-settings/domains.md#domain-aliases) as an alternative. If your dev environment uses a host that isn't configured as an alias, set `contextDomain` to override it:

```ts theme={"theme":"css-variables"}
export default defineNuxtConfig({
  modules: ["@frontic/nuxt"],
  frontic: {
    contextDomain: import.meta.dev ? "demo-shop.com" : undefined,
  },
});
```

##### Per-locale domains

A multi-locale storefront needs a different `contextDomain` per locale, because each of your [domain rows](https://docs.frontic.com/project-settings/domains.md) binds one scope / region / locale triple. Setting `contextDomain: true` tells the module to read the domain off the locale that `@nuxtjs/i18n` currently has active, so it follows locale switches without any per-call wiring.

`contextDomain` is not an i18n feature - it's a custom property you add to each locale object, which the module then reads through i18n's `localeProperties`:

```ts theme={"theme":"css-variables"}
export default defineNuxtConfig({
  modules: ["@frontic/nuxt", "@nuxtjs/i18n"],

  frontic: {
    contextDomain: true,
  },

  i18n: {
    defaultLocale: "en",
    locales: [
      { code: "en", contextDomain: "demo-shop.com" },
      { code: "de", contextDomain: "demo-shop.com/de" },
      { code: "fr", contextDomain: "demo-shop.com/fr" },
    ],
  },
});
```

Each value must match a domain row in **Settings → Domains**. With the rows above, a visitor on the `de` locale resolves to the German region and locale, and `useFronticPage` looks up `demo-shop.com/de/produkte/schuhe` for the path `/produkte/schuhe`.

> [!NOTE]
>
> `contextDomain: true` without `@nuxtjs/i18n` installed, or a locale object with no `contextDomain` property, logs a warning at build or request time and sends no domain - Frontic then falls back to the project's default scope, region, and locale rather than erroring. Watch for that warning if a locale silently serves the wrong market.

##### Resolution order

Every composable resolves the domain the same way, taking the first that applies:

1. The `contextDomain` passed in the composable's own options - per call, and reactive.
2. The module's `contextDomain` string.
3. The active i18n locale's `contextDomain`, when the module option is `true`.
4. Nothing. `useFronticPage` alone falls back once more, to the request host.

`useFronticClient` additionally accepts `contextDomain: false` to send no domain at all, ignoring the module config:

```ts theme={"theme":"css-variables"}
const client = useFronticClient({ contextDomain: false });
```

The resolved value travels as the `fs-domain` header and is part of every cache key - see [Caching](#caching).

##### The context token

A domain resolves to one scope, region and locale - the one you configured. The **context token** is what a visitor changes when they pick a different region or locale through [`useFronticContext`](#usefronticcontext), and Frontic gives it precedence over the domain.

You do not pass it per call. Every composable reads the token from the context cookie and sends it as `fs-context`, so a switch reaches the next request on its own:

```ts theme={"theme":"css-variables"}
const { update } = useFronticContext();

// Every block, listing, tree and page now resolves in the German region
await update({ region: "de", locale: "de-DE" });
```

The token is part of every cache key, so the switch moves each active query onto a new entry and its content is fetched rather than reused.

Two ways out, per call or per project:

* `contextKey: false` on a single call sends no token, so it resolves through the domain alone. Reach for it when the response goes behind a shared cache, where one visitor's context must not decide what the next one sees.
* `disableContext: true` stops the module managing the token at all - nothing is read, nothing is sent, and you pass `contextKey` yourself.

> [!NOTE]
>
> `useFronticContext({ cookieName })` overrides the cookie per instance. The
> composables resolve their token from the **first** scope registered that way,
> since queries have already keyed on it by the time a second one could move it;
> a second call naming a different cookie warns in development. Pass
> `contextKey` explicitly for that second context.

#### Frontic UI

The module configures [Frontic UI](https://docs.frontic.com/reference/frontic-ui.md) - a set of ready-to-use, customizable components designed for Frontic storefronts. When enabled, the module auto-imports your Frontic UI components so they're available everywhere in your app without manual imports, mirroring Nuxt's built-in component auto-import.

> [!NOTE]
>
> Frontic UI is currently in **open beta**. We'd love to hear your feedback! If
> you have questions or suggestions, please [get in touch with
> us](mailto:support@frontic.com).

**componentsPrefix** `string`

Prefix for auto-imported Frontic UI components from your component directory. For example, setting `'Ui'` would make `Button.vue` available as `<UiButton />`.

**componentDir** `string` (default: @/components/ui)

Directory path for Frontic UI components to auto-import.

#### Composables

**composables** `boolean | FronticComposable[]` (default: true)

Control which composables are auto-imported during the Nuxt build.

**Options**

**true** `boolean`

Import all composables.

**false** `boolean`

Disable all composable imports.

**FronticComposable\[]** `array`

Import only specific composables. Valid values:

* `'block'` → useFronticBlock
* `'listing'` → useFronticListing
* `'search'` → useFronticSearch
* `'tree'` → useFronticTree
* `'page'` → useFronticPage
* `'context'` → useFronticContext
* `'client'` → useFronticClient

> [!NOTE]
>
> When a composable is **disabled**, it will not be auto-imported during the Nuxt build. You won't get type errors or warnings - the composable won't exist. Use this to reduce bundle size by excluding composables you don't need.

***

## Composables

The Frontic composables provide a **smart data layer** for your Nuxt application. Built on [Pinia Colada](https://pinia-colada.esm.dev/), they handle caching, deduplication, and SSR hydration automatically - instant UI updates via stale-while-revalidate, shared requests across components, and server-to-client data transfer without extra configuration.

> [card] [useFronticBlock](#usefronticblock)
>
> Fetch single blocks (products, categories) with smart caching

> [card] [useFronticListing](#usefronticlisting)
>
> Fetch listings with automatic caching and SSR support

> [card] [useFronticSearch](#usefronticsearch)
>
> Full-featured search with filters, sorting, and smart state management

> [card] [useFronticTree](#usefrontictree)
>
> Hierarchical menu trees with automatic caching and SSR support

> [card] [useFronticPage](#usefronticpage)
>
> Dynamic page routing with redirects and 404 handling

> [card] [useFronticContext](#usefronticcontext)
>
> Locale and region switching with cookie persistence

> [card] [useFronticClient](#usefronticclient)
>
> Low-level client for direct API access

***

### `useFronticBlock`

Fetch a single block (product, category, brand) by key with automatic caching and SSR support. See [Caching](#caching) for details.

#### Parameters

**name** `BlockName` *required*

The name of the block to fetch. Provides full autocomplete for your generated
block types.

**key** `string | Ref\<string\>` *required*

The key identifier for the block. Can be reactive for dynamic fetching.

**options** `object`

Configuration options.

**Request Options**

**contextKey** `string | false | Ref\<string\>`

Override the context token. Resolved from the context cookie when omitted -
see [The context token](#the-context-token). `false` sends none, so the
request resolves through the domain alone.

**contextDomain** `string | Ref\<string\>`

Context domain to override the global setting for this request.

**requestUrl** `string | false | Ref\<string | false\>`

Request URL recorded with the request, for analytics and attribution. Defaults to the current URL.

Pass `false` to send nothing. The URL differs per visitor, so it varies a request that is otherwise identical - enough to fragment a shared cache in front of the Fetch API. It is not part of the cache key either way, so switching it off changes nothing about how entries are stored.

**staleTime** `number` (default: 300000)

Cache duration in milliseconds. Default: 5 minutes.

**gcTime** `number` (default: 300000)

How long an unused cache entry is kept before it is garbage collected. An
entry no component is reading survives this long, so returning to the route
within the window renders from cache instead of refetching.

**enabled** `boolean | Ref\<boolean\> | (() =\> boolean)` (default: true)

Whether the query fetches. While `false` no request is made; flipping it back
to `true` refreshes. Use it when an input the query depends on is not known
yet, rather than calling the composable conditionally.

**keepPreviousData** `boolean` (default: true)

Keep the data already on screen while a changed key - a new page, filter or
context - loads, instead of dropping back to `pending` and blanking the view.
The held value is never written to the cache. First loads still report
`pending`; track the background load through `asyncStatus` or `isLoading`.

#### Returns

**block** `Ref\<ResponseOf\<T\> | undefined\>`

The block data, fully typed based on the block name.

**status** `Ref\<'pending' | 'error' | 'success'\>`

Current query status for loading states.

**asyncStatus** `Ref\<'idle' | 'loading'\>`

What the network is doing, independent of what the data is. Together with
`status` this separates a first load (`pending`) from a background refresh
(`success` while `asyncStatus` is `'loading'`) - the state `keepPreviousData`
produces.

**isLoading** `Ref\<boolean\>`

Shorthand for `asyncStatus === 'loading'`.

**error** `ShallowRef\<Error | null\>`

The error thrown by the last fetch - `null` while pending or after success. See [Error handling](#error-handling).

**refresh** `() =\> Promise\<void\>`

Refresh data using cache if still valid.

**refetch** `() =\> Promise\<void\>`

Force a fresh fetch, bypassing cache entirely.

#### Example

```ts theme={"theme":"css-variables"}
<script setup lang="ts">
const route = useRoute()
const { block: product, status } = useFronticBlock('ProductFull', route.params.id)
</script>

<template>
  <div v-if="status === 'pending'">Loading...</div>
  <div v-else-if="product">
    <h1>{{ product.name }}</h1>
    <p>{{ product.price.formatted }}</p>
  </div>
</template>
```

> [accordion] More Examples
>
> ```ts theme={"theme":"css-variables"}
> // With options
> const { block } = useFronticBlock("ProductCard", productId, {
>   contextKey: contextToken,
>   staleTime: 1000 * 60 * 10, // 10 minutes
> });
>
> // Reactive key - automatically refetches when key changes
> const productKey = ref("product-123");
> const { block } = useFronticBlock("ProductCard", productKey);
> ```

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type { Ref, ShallowRef, MaybeRef } from "vue";
> import type { BlockName, ResponseOf } from "@frontic/nuxt/types";
>
> function useFronticBlock<T extends BlockName>(
>   name: T,
>   key: MaybeRef<string>,
>   options?: {
>     contextKey?: MaybeRef<string | undefined>;
>     contextDomain?: MaybeRef<string | undefined>;
>     requestUrl?: MaybeRef<string | false | undefined>;
>     staleTime?: number;
>     gcTime?: number;
>     enabled?: MaybeRef<boolean> | (() => boolean);
>     keepPreviousData?: boolean;
>   },
> ): {
>   block: Ref<ResponseOf<T> | undefined>;
>   status: Ref<"pending" | "error" | "success">;
>   asyncStatus: Ref<"idle" | "loading">;
>   isLoading: Ref<boolean>;
>   error: ShallowRef<Error | null>;
>   refresh: () => Promise<void>;
>   refetch: () => Promise<void>;
> };
> ```

***

### `useFronticListing`

Fetch a listing by name and parameters with automatic caching and SSR support. Mirrors `client.listing(name, params, { query })` - including the optional `query` for `filter`, `sort`, `search`, `limit`, and `page`. See [Caching](#caching) for details.

> [!NOTE]
>
> Building a page with **interactive** filter/sort/search controls or
> pagination bound to user input? Use [`useFronticSearch`](#usefronticsearch).
> `useFronticListing` is the right pick when the query is fixed at call time -
> homepage carousels with `{ limit: 10 }`, server-rendered grids with a static
> filter, etc.

#### Parameters

**name** `ListingName` *required*

The name of the listing to fetch.

**params** `ListingParamsOf\<T\> | Ref`

Parameters to pass to the listing endpoint. Type-safe based on listing name.

Optional when every parameter of the listing is optional - `useFronticListing("ProductSearch")` needs no argument, while a listing with a required parameter such as `parentKey` still does. Options stay the third argument, so pass `{}` here when you set options on a listing without parameters.

**options** `object`

Configuration options.

**Query Options**

**query** `ListingQuery\<T\> | Ref`

Query forwarded directly to the Fetch API as `client.listing(name, params, { query })`. Fully typed against your generated listing schema - `filter` field keys and `sort` field/order resolve to the autocomplete-typed shape for the specific listing.

```ts theme={"theme":"css-variables"}
query: {
  filter: [{ type: 'equals', field: 'properties.color', value: 'red' }],
  sort: { field: 'price.amount', order: 'asc' },
  limit: 20,
}
```

Accepts a reactive ref. `query` is part of the [cache key](#what-a-cache-entry-is-keyed-by), so distinct values cache independently and the listing refetches when the ref changes.

**Request Options**

**contextKey** `string | false | Ref\<string\>`

Override the context token. Resolved from the context cookie when omitted -
see [The context token](#the-context-token). `false` sends none, so the
request resolves through the domain alone.

**contextDomain** `string | Ref\<string\>`

Context domain to override the global setting for this request.

**requestUrl** `string | false | Ref\<string | false\>`

Request URL recorded with the request, for analytics and attribution. Defaults to the current URL.

Pass `false` to send nothing. The URL differs per visitor, so it varies a request that is otherwise identical - enough to fragment a shared cache in front of the Fetch API. It is not part of the cache key either way, so switching it off changes nothing about how entries are stored.

**staleTime** `number` (default: 300000)

Cache duration in milliseconds.

**gcTime** `number` (default: 300000)

How long an unused cache entry is kept before it is garbage collected. An
entry no component is reading survives this long, so returning to the route
within the window renders from cache instead of refetching.

**enabled** `boolean | Ref\<boolean\> | (() =\> boolean)` (default: true)

Whether the query fetches. While `false` no request is made; flipping it back
to `true` refreshes. Use it when an input the query depends on is not known
yet, rather than calling the composable conditionally.

**keepPreviousData** `boolean` (default: true)

Keep the data already on screen while a changed key - a new page, filter or
context - loads, instead of dropping back to `pending` and blanking the view.
The held value is never written to the cache. First loads still report
`pending`; track the background load through `asyncStatus` or `isLoading`.

**initialData** `() =\> ResponseOf\<T\> | undefined`

Data to seed the cache with, when the listing already travelled with the page
or block that renders it. The query reports `success` on the first paint and
makes no request while the seed is within `staleTime` - on the server, and
after hydration too. See [Seeding data that arrived with the page](#seeding-data-that-arrived-with-the-page).

Returning `undefined` seeds nothing and fetches as usual, so the getter may
read a payload that has not resolved yet without a guard of its own.

**initialDataUpdatedAt** `number | (() =\> number)`

When the seeded data was produced, as an epoch millisecond timestamp. Drives
its staleness: with the default `staleTime`, a seed stamped ten minutes ago
refreshes in the background on mount instead of being trusted. Worth setting
whenever the payload outlives the request that renders it - an ISR or
CDN-cached page hands the same block to every visitor. Defaults to now.

#### Returns

**listing** `Ref\<ResponseOf\<T\> | undefined\>`

The listing data with items and metadata.

**status** `Ref\<'pending' | 'error' | 'success'\>`

Current query status.

**asyncStatus** `Ref\<'idle' | 'loading'\>`

What the network is doing, independent of what the data is. Together with
`status` this separates a first load (`pending`) from a background refresh
(`success` while `asyncStatus` is `'loading'`) - the state `keepPreviousData`
produces.

**isLoading** `Ref\<boolean\>`

Shorthand for `asyncStatus === 'loading'`.

**error** `ShallowRef\<Error | null\>`

The error thrown by the last fetch - `null` while pending or after success. See [Error handling](#error-handling).

**refresh** `() =\> Promise\<void\>`

Refresh using cache if valid.

**refetch** `() =\> Promise\<void\>`

Force fresh fetch.

#### Example

```ts theme={"theme":"css-variables"}
<script setup lang="ts">
const { listing, status } = useFronticListing('CategoryProducts', {
  key: 'shoes',
})
</script>

<template>
  <div v-if="status === 'pending'">Loading...</div>
  <div v-else>
    <div v-for="item in listing?.items" :key="item.key">{{ item.name }}</div>
  </div>
</template>
```

> [accordion] More Examples
>
> ```ts theme={"theme":"css-variables"}
> // Reactive params - refetches when category changes
> const categoryKey = ref("shoes");
>
> const { listing } = useFronticListing(
>   "CategoryProducts",
>   computed(() => ({ key: categoryKey.value })),
> );
>
> // No parameters - ProductSearch has no required ones
> const { listing } = useFronticListing("ProductSearch");
>
> // Static query - featured-products carousel with a fixed limit
> const { listing } = useFronticListing(
>   "ProductSearch",
>   {},
>   { query: { limit: 10 } },
> );
>
> // Typed filter + sort
> const { listing } = useFronticListing(
>   "ProductSearch",
>   {},
>   {
>     query: {
>       filter: [{ type: "equals", field: "properties.color", value: "red" }],
>       sort: { field: "price.amount", order: "asc" },
>       limit: 20,
>     },
>   },
> );
>
> // Reactive query - refetches when page changes
> const page = ref(1);
> const { listing } = useFronticListing(
>   "ProductSearch",
>   {},
>   { query: computed(() => ({ limit: 20, page: page.value })) },
> );
> ```

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type { Ref, ShallowRef, MaybeRef } from "vue";
> import type {
>   ListingName,
>   ListingParamsOf,
>   ListingQuery,
>   ResponseOf,
> } from "@frontic/nuxt/types";
>
> // `params` is optional when every field of ListingParamsOf<T> is optional
> function useFronticListing<T extends ListingName>(
>   name: T,
>   params?: MaybeRef<ListingParamsOf<T>>,
>   options?: {
>     query?: MaybeRef<ListingQuery<T> | undefined>;
>     contextKey?: MaybeRef<string | undefined>;
>     contextDomain?: MaybeRef<string | undefined>;
>     requestUrl?: MaybeRef<string | false | undefined>;
>     staleTime?: number;
>     gcTime?: number;
>     enabled?: MaybeRef<boolean> | (() => boolean);
>     keepPreviousData?: boolean;
>     initialData?: () => ResponseOf<T> | undefined;
>     initialDataUpdatedAt?: number | (() => number);
>   },
> ): {
>   listing: Ref<ResponseOf<T> | undefined>;
>   status: Ref<"pending" | "error" | "success">;
>   asyncStatus: Ref<"idle" | "loading">;
>   isLoading: Ref<boolean>;
>   error: ShallowRef<Error | null>;
>   refresh: () => Promise<void>;
>   refetch: () => Promise<void>;
> };
> ```

***

### `useFronticSearch`

A ready-to-use backend for building stateful search and filter UIs. This composable handles all the interaction logic - filtering, sorting, pagination, and text search - with automatic caching and SSR support, so you can focus on crafting the perfect UI. See [Caching](#caching) for details.

It follows best practices to reduce logic overhead in your templates, provides pre-processed filter and sort options with labels and counts, and seamlessly integrates with Frontic UI components.

#### Parameters

**name** `ListingName` *required*

The name of the listing to fetch.

**params** `ListingParamsOf\<T\> | Ref`

Parameters to pass to the listing endpoint. Optional when every parameter of the listing is optional - see [`useFronticListing`](#usefronticlisting).

**options** `object`

Configuration options.

**Search Options**

**disableSearch** `boolean` (default: false)

When `true`, the `searchTerm` ref is ignored and no search parameter is sent
to the API. Use this when your listing doesn't support text search.

**searchTermThreshold** `number` (default: 2)

Minimum number of characters required before a search request is triggered.
Prevents API calls for very short, likely meaningless queries.

**searchDebounce** `number` (default: 200)

Debounce delay in milliseconds for search input. Controls how long to wait
after the user stops typing before triggering a search request. Increase for
slower networks or to reduce API calls.

**analytics** `object`

Thresholds for the [`frontic:search:committed`](#listing-and-search) hook, separate from the search behaviour above.

**Options**

**searchCommitMs** `number` (default: 1000)

How long a term must rest before it counts as a search.

**searchMinChars** `number` (default: 3)

Shortest term worth recording.

**Filter Options**

**disableFilter** `boolean` (default: false)

When `true`, filter methods (`addFilter`, `removeFilter`, etc.) are disabled and `state.available.filter` remains empty. Use this when your UI doesn't need filtering controls.

**orFilter** `string[]`

A list of filter field names that should use OR logic, allowing users to select multiple values (e.g., "red OR blue"). Fields not listed use AND logic by default.

```ts theme={"theme":"css-variables"}
orFilter: ['properties.color', 'properties.size']
```

**filter.select** `string[]`

A list of filter field keys to control which filters appear in `state.available.filter`. Combined with `mode` to include or exclude specific filters.

```ts theme={"theme":"css-variables"}
filter: {
  select: ['properties.color', 'properties.size', 'properties.brand']
}
```

**filter.mode** `'include' | 'exclude'` (default: include)

Controls how `select` is applied. With `'include'`, only the specified fields are shown. With `'exclude'`, all filters except the specified fields are shown.

**filter.label** `Record\<string, string\>`

Human-readable labels for filter fields. These appear in `state.available.filter` for building filter UI headings.

```ts theme={"theme":"css-variables"}
filter: {
  label: {
    'properties.color': 'Color',
    'properties.size': 'Size',
    'properties.brand': 'Brand'
  }
}
```

**filter.sort** `string[]`

Define the display order for filters. Keys listed here appear first in `state.available.filter` in the specified order. Any filters not in this array appear after in their original order.

```ts theme={"theme":"css-variables"}
filter: {
  sort: ['properties.color', 'properties.size', 'properties.brand']
}
```

**filter.type** `Record\<string, string\>` (default: default)

Display type per filter field, surfaced as `type` on each entry of `state.available.filter`. The value is passed through untouched - use whatever your UI branches on. Fields without an entry get `'default'`.

```ts theme={"theme":"css-variables"}
filter: {
  type: { 'price.amount': 'range', 'properties.isNew': 'boolean' }
}
```

```vue theme={"theme":"css-variables"}
<component :is="filter.type === 'range' ? RangeSlider : CheckboxGroup" />
```

**Sort Options**

**disableSorting** `boolean` (default: false)

When `true`, sorting methods (`sortResult`, `resetSorting`) are disabled and `state.available.sorting` remains empty. Use this when your UI doesn't need sorting controls.

**sorting.select** `SortString\<T\>[]`

A list of sort options to control which sorts appear in `state.available.sorting`. Use the format `'field:order'` (e.g., `'name:asc'`). Use `'default'` for the backend's default sorting. Values are type-checked against the listing's sortable fields, so a typo fails to compile.

```ts theme={"theme":"css-variables"}
sorting: {
  select: ['default', 'name:asc', 'name:desc', 'price.amount:asc']
}
```

**sorting.mode** `'include' | 'exclude'` (default: include)

Controls how `select` is applied. With `'include'`, only the specified sort options are shown. With `'exclude'`, all sorts except the specified options are shown.

**sorting.label** `Partial\<Record\<SortString\<T\>, string\>\>`

Human-readable labels for sort options. These appear in `state.available.sorting` for building sort dropdowns. Use `'default'` key for the backend's default sorting.

```ts theme={"theme":"css-variables"}
sorting: {
  label: {
    'default': 'Relevance',
    'name:asc': 'Name A-Z',
    'name:desc': 'Name Z-A',
    'price.amount:asc': 'Price: Low to High',
    'price.amount:desc': 'Price: High to Low'
  }
}
```

**sorting.sort** `SortString\<T\>[]`

Define the display order for sort options. Keys listed here appear first in `state.available.sorting` in the specified order. Any sorts not in this array appear after in their original order.

```ts theme={"theme":"css-variables"}
sorting: {
  sort: ['default', 'price.amount:asc', 'price.amount:desc', 'name:asc']
}
```

**Pagination Options**

**limit** `number | Ref\<number\>`

Items per page (sent as `limit` to the Frontic API). When omitted, the backend default is used.

Accepts a ref, so a page size that depends on something resolved after setup refetches when it changes.

**infinite** `boolean` (default: false)

When `true`, `loadNext()` / `loadPrev()` append/prepend items from adjacent pages (infinite-scroll mode). When `false` (default), the same calls replace the current page.

**initialPage** `number` (default: 1)

Starting page number. Useful in infinite-scroll mode when deep-linking to a specific page from the URL.

**initialSearch** `string`

Search term to start from, committed before the first query runs. Use this for a term that arrives with the request - see [Restoring state from the URL](#restoring-state-from-the-url).

**initialFilters** `object`

Filters to start from, keyed by field, in the same shape `filterResult` takes. Range values are recognised by shape; every other field is routed through `orFilter` exactly as an interaction would route it.

**initialSort** `string`

Sort to start from, in the same `'field:order'` form `sortResult` takes. Ignored when the sorting config disallows it, like any other sort.

**URL Sync Options**

**urlQuerySync** `boolean | SearchUrlQuerySyncOptions` (default: false)

Two-way sync of the search term, sorting, filters, and page with the URL query string. State is restored from the URL on load - server-side, so the first render already matches the URL - and kept in sync as the user interacts, making URLs shareable, reload-safe, and back/forward-aware. Off by default; no router APIs are touched unless enabled. Pass `true` for defaults, or an object to customise.

```ts theme={"theme":"css-variables"}
urlQuerySync: {
  params: { search: "q", "properties.color": "color", "price.amount": "price" },
}
// → ?q=boot&color=Red&color=Blue&price_min=20&sort=name:asc
```

Equals filters use repeated params (`?color=Red&color=Blue`); range filters use `_min`/`_max` bounds (either may be omitted); `page` appears only from page 2 on. Unrelated params (`utm_source`, etc.) are always preserved. Sort values from the URL are validated against your `sorting.label` keys; filter values round-trip as strings.

**urlQuerySync.prefix** `string` (default: '')

Namespaces every param this instance owns. Required to run two synced searches on one page without their params colliding (e.g. `'a_'` and `'b_'`).

**urlQuerySync.include** `Array\<'search' | 'sort' | 'filter' | 'page'\>`

Which state slices to sync. Defaults to all four. Also gated by the matching `disable*` option.

**urlQuerySync.params** `Record\<'search' | 'sort' | 'page' | FilterField, string\>`

Rename URL params. Keys are a slice name or a filter field; values are the param name. Defaults: `q`, `sort`, `page`, and raw field names. Range filters get `_min`/`_max` suffixes. Unrelated to the composable's second `params` argument (the backend listing parameters).

**urlQuerySync.history** `'replace' | 'push'` (default: replace)

History strategy for URL writes. `'replace'` avoids a history entry per interaction (Back leaves the page); `'push'` makes Back an undo.

**Request Options**

**contextKey** `string | false | Ref`

Override the context token. Resolved from the context cookie when omitted -
see [The context token](#the-context-token). `false` sends none.

**contextDomain** `string | Ref`

Context domain to override the global setting for this request.

**requestUrl** `string | false | Ref\<string | false\>`

Request URL recorded with the request, for analytics and attribution. Defaults to the current URL.

Pass `false` to send nothing. The URL differs per visitor, so it varies a request that is otherwise identical - enough to fragment a shared cache in front of the Fetch API. It is not part of the cache key either way, so switching it off changes nothing about how entries are stored.

**staleTime** `number` (default: 300000)

Cache duration in milliseconds.

**gcTime** `number` (default: 300000)

How long an unused cache entry is kept before it is garbage collected. An
entry no component is reading survives this long, so returning to the route
within the window renders from cache instead of refetching.

**enabled** `boolean | Ref\<boolean\> | (() =\> boolean)` (default: true)

Whether the query fetches. While `false` no request is made; flipping it back
to `true` refreshes. Use it when an input the query depends on is not known
yet, rather than calling the composable conditionally.

**keepPreviousData** `boolean` (default: true)

Keep the data already on screen while a changed key - a new page, filter or
context - loads, instead of dropping back to `pending` and blanking the view.
The held value is never written to the cache. First loads still report
`pending`; track the background load through `asyncStatus` or `isLoading`.

**initialData** `() =\> ResponseOf\<T\> | undefined`

Data to seed the cache with, when the listing already travelled with the page
or block that renders it. The query reports `success` on the first paint and
makes no request while the seed is within `staleTime` - on the server, and
after hydration too. See [Seeding data that arrived with the page](#seeding-data-that-arrived-with-the-page).

Returning `undefined` seeds nothing and fetches as usual, so the getter may
read a payload that has not resolved yet without a guard of its own.

**initialDataUpdatedAt** `number | (() =\> number)`

When the seeded data was produced, as an epoch millisecond timestamp. Drives
its staleness: with the default `staleTime`, a seed stamped ten minutes ago
refreshes in the background on mount instead of being trusted. Worth setting
whenever the payload outlives the request that renders it - an ISR or
CDN-cached page hands the same block to every visitor. Defaults to now.

**cacheKey** `string` (default: name)

Key the search state is stored under. Calls sharing a `cacheKey` share the search term, filters, sorting and page - see the "Shared State with Wrapper Composable" example below.

Two calls with the same `cacheKey` but different `params` would overwrite each other's state, so the module logs a warning in development when it sees that. Give them distinct keys.

#### Returns

##### State & Cache Control

**result** `ShallowRef\<ResponseOf\<T\> | undefined\>`

The full listing response from the API, containing items, pagination metadata, and filter facets.

```ts theme={"theme":"css-variables"}
// Access pagination info
const hasMore = result.value?.page?.next !== undefined
// Access filter facets
result.value?.filter
// Iterate over items
result.value?.items.forEach(item => console.log(item.name))
```

**status** `Ref\<'pending' | 'error' | 'success'\>`

Current query status. Use this to show loading spinners or error states in your UI.

```ts theme={"theme":"css-variables"}
<div v-if="status === 'pending'">Loading...</div>
<div v-else-if="status === 'error'">Something went wrong</div>
<div v-else>{{ result?.length }} products found</div>
```

**asyncStatus** `Ref\<'idle' | 'loading'\>`

What the network is doing, independent of what the data is. Together with
`status` this separates a first load (`pending`) from a background refresh
(`success` while `asyncStatus` is `'loading'`) - the state `keepPreviousData`
produces.

**isLoading** `Ref\<boolean\>`

Shorthand for `asyncStatus === 'loading'`.

**error** `ShallowRef\<Error | null\>`

The error thrown by the last fetch - `null` while pending or after success. See [Error handling](#error-handling).

**state** `Ref\<SearchState\<TFilters, TSorts\>\>`

Pre-processed search state ready for building your UI. Fully typed based on your listing's filter and sort schema - with IDE autocomplete for filter keys, sort fields, and more. Contains everything needed to render filter sidebars, sort dropdowns, and pagination controls.

**SearchState Properties**

**active** `object`

Currently active query parameters.

**Properties**

**filter** `Partial\<Record\<keyof TFilters, unknown\>\>`

Active filter values by field. Keys are typed based on your listing's filter schema -get full autocomplete.

```ts theme={"theme":"css-variables"}
// Keys are typed - autocomplete available!
state.value.active.filter['properties.color']  // ✅ autocomplete
state.value.active.filter['invalid.field']     // ❌ type error
```

**sorting** `\{ field: keyof TSorts; order: 'asc' | 'desc' \} | undefined`

Currently applied sort (typed based on listing schema), or `undefined` when using default sorting.

**search** `string`

Current search term (empty string when not searching).

**page** `number`

Current page number (1-indexed).

**count** `object`

Counts for active state.

**Properties**

**filter** `number`

Number of filter fields with active selections. Use this to show a badge on your "Filters" button.

```ts theme={"theme":"css-variables"}
<button>Filters <span v-if="state.active.count.filter">({{ state.active.count.filter }})</span></button>
```

**sorting** `number`

Number of active sorts (0 or 1).

**available** `object`

Available options for UI controls.

**Properties**

**filter** `UiFilter\<TFilters\>[]`

Pre-processed filter options ready for rendering. Each filter's `key` is typed based on your listing schema.

```ts theme={"theme":"css-variables"}
interface UiFilter<TFilters> {
  key: keyof TFilters  // Typed! e.g., 'properties.color'
  label: string        // e.g., 'Color' (from your config)
  type: string         // From `filter.type`, or 'default'
  options: Array<{
    option: string   // Display name, e.g., 'Red'
    value: string    // API value, e.g., 'red'
    count: number    // Number of matching items
    selected: boolean
    disabled: boolean
  }>
  data?: {           // Present when the response carries an aggregation
    total: { min?: number; max?: number; avg?: number; sum?: number; total: number }
    result: { min?: number; max?: number; avg?: number; sum?: number; total: number }
  }
}
```

`data` is what a range control needs for its bounds: `total` spans the whole set, `result` the current filtered view. A field that carries an aggregation but no facet options still appears here - that is how a price range reaches the UI.

**sorting** `UiSort\<TSorts\>[]`

Pre-processed sort options ready for rendering dropdowns. Keys are typed based on your listing schema.

```ts theme={"theme":"css-variables"}
interface UiSort<TSorts> {
  key: SortStringOf<TSorts>    // e.g., 'name:asc' or 'default'
  label: string                // e.g., 'Name A-Z' (from your config)
  value: SortStringOf<TSorts>  // Pass to sortResult
}
```

**nextPage** `boolean`

Whether a next page is available. Use for pagination controls.

**prevPage** `boolean`

Whether a previous page is available. Use for pagination controls.

**count** `object`

Counts for available options.

**Properties**

**filter** `number`

Number of filter fields available.

**sorting** `number`

Number of sort options available.

**result** `number`

Total number of items matching the current query.

```ts theme={"theme":"css-variables"}
<p>{{ state.available.count.result }} products found</p>
```

**page** `number`

Total number of pages. Use this to display "Page X of Y".

```ts theme={"theme":"css-variables"}
<p>Page {{ state.active.page }} of {{ state.available.count.page }}</p>
```

**searchTerm** `Ref\<string\>`

Two-way bindable search term. Connect this directly to your search input -
typing automatically triggers debounced API requests when the term exceeds
`searchTermThreshold`.

**refresh** `() =\> Promise\<void\>`

Re-fetch data, using cache if still valid within `staleTime`. Use this when
you want to ensure fresh data but don't need to bypass the cache.

**refetch** `() =\> Promise\<void\>`

Force a fresh fetch, completely bypassing the cache. Use this when you know
data has changed and need guaranteed fresh results.

**reset** `() =\> Promise\<void\>`

Reset all state (search term, filters, and sorting) to initial values and refresh results. Equivalent to calling `resetSearch()`, `resetFilter()`, and `resetSorting()` together. Use this for a "Clear All" button.

```ts theme={"theme":"css-variables"}
<button @click="reset">Clear All Filters & Search</button>
```

##### Filter Actions

**addFilter** `(field, value) =\> Promise\<void\>`

Add a single filter value while keeping existing selections. Use this for checkbox-style filters where users can select multiple options.

```ts theme={"theme":"css-variables"}
// User clicks "Red" checkbox
await addFilter('properties.color', 'red')

// User also selects "Blue" - both are now active
await addFilter('properties.color', 'blue')

// Range filters take a { from, to } object; either bound may be omitted
await addFilter('price.amount', { from: 20, to: 100 })
await addFilter('price.amount', { from: 50 }) // replaces the range above
```

A `{ from, to }` value is recognised by shape and routed to the range filter list. Ranges do not accumulate: a field holds one range, and a second call replaces it.

**removeFilter** `(field, value?) =\> Promise\<void\>`

Remove a specific filter value, or clear all values for a field if no value is provided. Use this when users uncheck options or click "clear" on a filter group.

```ts theme={"theme":"css-variables"}
// User unchecks "Red"
await removeFilter('properties.color', 'red')

// User clicks "Clear all colors"
await removeFilter('properties.color')

// Clears the range on that field, with or without a value argument
await removeFilter('price.amount')
```

The field's range is always cleared, whichever `value` is passed - a range has no single value to remove.

**filterResult** `(field, values) =\> Promise\<void\>`

Replace all values for a filter field at once. Use this for single-select filters or when setting multiple values programmatically.

```ts theme={"theme":"css-variables"}
// Single-select dropdown changed to "Large"
await filterResult('properties.size', ['large'])

// Set multiple values at once
await filterResult('properties.color', ['red', 'blue', 'green'])

// Range filter - pass the object as the single array entry
await filterResult('price.amount', [{ from: 20, to: 100 }])
```

When the first entry is a `{ from, to }` object the call is treated as a range and the remaining entries are ignored.

**resetFilter** `(field?) =\> Promise\<void\>`

Clear filters for a specific field, or all filters if no field is provided. Use this for "Reset" buttons.

```ts theme={"theme":"css-variables"}
// Clear just color filters
await resetFilter('properties.color')

// Clear ALL filters
await resetFilter()
```

##### Sort Actions

**sortResult** `(sortBy?: SortStringOf\<TSorts\>) =\> Promise\<void\>`

Apply a sort order using the `'field:order'` format. Call without arguments to reset to default sorting.

```ts theme={"theme":"css-variables"}
// Sort by name ascending
await sortResult('name:asc')

// Sort by price descending
await sortResult('price.amount:desc')

// Reset to default (backend's default order)
await sortResult()

```

**resetSorting** `() =\> Promise\<void\>`

Reset to the backend's default sort order. Equivalent to calling `sortResult()` without arguments.

```ts theme={"theme":"css-variables"}
await resetSorting()
```

##### Pagination Actions

> [!NOTE]
>
> Filtering, sorting, or searching for a new term returns you to page 1 - or to
> `initialPage` with `infinite: true`. A pagination component bound to `state.active.page` follows this on its own; one
> holding its own page number will drift out of step with the results.

**loadNext** `() =\> Promise\<ResponseOf\<T\> | undefined\>`

Load the next page. In **standard mode** (the default), the current page is replaced with the next one. In **infinite mode** (`infinite: true`), items from the next page are appended to the existing list - use this for "Load More" buttons or infinite scroll.

```ts theme={"theme":"css-variables"}
<button v-if="result?.page?.next" @click="loadNext">
  Load More Products
</button>
```

**loadPrev** `() =\> Promise\<ResponseOf\<T\> | undefined\>`

Load the previous page. In **standard mode**, the current page is replaced with the previous one. In **infinite mode**, items from the previous page are prepended to the existing list.

**loadPage** `(page: number) =\> Promise\<ResponseOf\<T\> | undefined\>`

Jump to a specific page number. In **standard mode**, the current page is replaced with the requested page. In **infinite mode**, all loaded pages are reset and the requested page becomes the new starting point.

##### Search Actions

**resetSearch** `() =\> Promise\<void\>`

Clear the search term and refresh results. Use this for a "clear search" button.

```ts theme={"theme":"css-variables"}
<button v-if="searchTerm" @click="resetSearch">✕</button>
```

#### Example

```ts theme={"theme":"css-variables"}
<script setup lang="ts">
const { result, state, searchTerm, addFilter, removeFilter, sortResult, loadNext, reset } = useFronticSearch(
  'CategoryProducts',
  { key: 'shoes' },
  {
    orFilter: ['properties.color', 'properties.size'],
    filter: {
      select: ['properties.color', 'properties.size', 'properties.brand'],
      label: { 'properties.color': 'Color', 'properties.size': 'Size' },
      sort: ['properties.color', 'properties.size'], // Display order for filters
    },
    sorting: {
      label: {
        default: 'Relevance',
        'name:asc': 'Name A-Z',
        'price.amount:asc': 'Price: Low to High',
      },
      sort: ['default', 'price.amount:asc', 'name:asc'], // Display order for sorts
    },
  }
)
</script>

<template>
  <input v-model="searchTerm" placeholder="Search..." />
  <button v-if="state.active.count.filter > 0" @click="reset">Clear All</button>
  <div v-for="item in result?.items" :key="item.key">{{ item.name }}</div>
  <button v-if="result?.page?.next" @click="loadNext">Load More</button>
</template>
```

> [accordion] Full Example with Filters
>
> ```ts theme={"theme":"css-variables"}
> <script setup lang="ts">
> const { result, state, searchTerm, addFilter, removeFilter, sortResult, loadNext } = useFronticSearch(
>   'CategoryProducts',
>   { key: 'shoes' },
>   {
>     orFilter: ['properties.color', 'properties.size'],
>     filter: {
>       select: ['properties.color', 'properties.size', 'properties.brand', 'price.amount'],
>       label: {
>         'properties.color': 'Color',
>         'properties.size': 'Size',
>         'properties.brand': 'Brand',
>         'price.amount': 'Price',
>       },
>       type: {
>         'price.amount': 'range',
>       },
>     },
>     sorting: {
>       label: {
>         default: 'Relevance',
>         'name:asc': 'Name A-Z',
>         'name:desc': 'Name Z-A',
>         'price.amount:asc': 'Price: Low to High',
>         'price.amount:desc': 'Price: High to Low',
>       },
>     },
>   }
> )
>
> // `sortResult` takes `'field:order'` or `'default'`, not any string, and a DOM value is a plain
> // string. The options come from `state.available.sorting`, so the value is known-good - assert it
> // against the parameter's own type rather than widening the signature.
> function onSortChange(event: Event) {
>   sortResult((event.target as HTMLSelectElement).value as Parameters<typeof sortResult>[0])
> }
>
> function toggleFilter(field: string, value: string, isSelected: boolean) {
>   if (isSelected) {
>     removeFilter(field, value)
>   } else {
>     addFilter(field, value)
>   }
> }
>
> function setPriceMax(event: Event) {
>   addFilter('price.amount', { to: Number((event.target as HTMLInputElement).value) })
> }
> </script>
>
> <template>
>   <div class="search-page">
>     <!-- Search Input -->
>     <input v-model="searchTerm" type="search" placeholder="Search products..." />
>
>     <!-- Sort Dropdown -->
>     <select @change="onSortChange">
>       <option v-for="sort in state.available.sorting" :key="sort.key" :value="sort.key" :selected="state.active.sorting?.field === sort.key.split(':')[0]">
>         {{ sort.label }}
>       </option>
>     </select>
>
>     <!-- Filter Sidebar -->
>     <aside>
>       <div v-for="filter in state.available.filter" :key="filter.key">
>         <h4>{{ filter.label }}</h4>
>
>         <!-- `type` comes from the `filter.type` config; bounds come from `data` -->
>         <input
>           v-if="filter.type === 'range'"
>           type="range"
>           :min="filter.data?.total.min"
>           :max="filter.data?.total.max"
>           @change="setPriceMax"
>         />
>
>         <template v-else>
>           <label v-for="option in filter.options" :key="option.value">
>             <input type="checkbox" :checked="option.selected" @change="toggleFilter(filter.key, option.value, option.selected)" />
>             {{ option.option }} ({{ option.count }})
>           </label>
>         </template>
>       </div>
>     </aside>
>
>     <!-- Results -->
>     <main>
>       <p>{{ state.available.count.result }} products found</p>
>       <div class="product-grid">
>         <article v-for="item in result?.items" :key="item.key">
>           <h3>{{ item.name }}</h3>
>           <p>{{ item.price.formatted }}</p>
>         </article>
>       </div>
>       <button v-if="result?.page?.next" @click="loadNext">Load More</button>
>     </main>
>   </div>
> </template>
> ```

> [accordion] Shared State with Wrapper Composable
>
> ```ts composables/useMySearch.ts theme={"theme":"css-variables"}
> /**
>  * Wrapper composable for product search.
>  *
>  * By using a fixed cacheKey, all components calling this composable
>  * share the same search state automatically via Nuxt's useState.
>  */
> export function useMySearch() {
>   return useFronticSearch(
>     "CategoryProducts",
>     { key: "shoes" },
>     {
>       // This cacheKey enables state sharing across components!
>       cacheKey: "product-search",
>
>       // Search configuration
>       orFilter: ["properties.color", "properties.size"],
>       filter: {
>         select: ["properties.color", "properties.size", "properties.brand"],
>         label: {
>           "properties.color": "Color",
>           "properties.size": "Size",
>           "properties.brand": "Brand",
>         },
>       },
>       sorting: {
>         label: {
>           default: "Relevance",
>           "name:asc": "Name A-Z",
>           "price.amount:asc": "Price: Low to High",
>         },
>       },
>     },
>   );
> }
> ```
>
> ```vue pages/index.vue theme={"theme":"css-variables"}
> <script setup lang="ts">
> // Both this page and the Filters component share the same search state
> const { result, status, searchTerm, state, loadNext } = useMySearch();
> </script>
>
> <template>
>   <div class="search-page">
>     <!-- Search input - shared with Filters component -->
>     <input v-model="searchTerm" placeholder="Search products..." />
>
>     <!-- Filter sidebar - uses same state -->
>     <Filters />
>
>     <!-- Results count reflects active filters from Filters component -->
>     <p>{{ state.available.count.result }} products found</p>
>
>     <!-- Product grid -->
>     <div v-if="status === 'pending'">Loading...</div>
>     <div v-else class="product-grid">
>       <article v-for="item in result?.items" :key="item.key">
>         <h3>{{ item.name }}</h3>
>         <p>{{ item.price.formatted }}</p>
>       </article>
>     </div>
>
>     <button v-if="state.available.nextPage" @click="loadNext">Load More</button>
>   </div>
> </template>
> ```
>
> ```vue components/Filters.vue theme={"theme":"css-variables"}
> <script setup lang="ts">
> // Same composable = same state! Changes here update index.vue automatically
> const { state, addFilter, removeFilter, resetFilter } = useMySearch();
>
> function toggleFilter(field: string, value: string, isSelected: boolean) {
>   if (isSelected) {
>     removeFilter(field, value);
>   } else {
>     addFilter(field, value);
>   }
> }
> </script>
>
> <template>
>   <aside class="filters">
>     <div class="filter-header">
>       <h3>Filters</h3>
>       <!-- Active filter count updates when filters change -->
>       <button v-if="state.active.count.filter > 0" @click="resetFilter()">
>         Clear all ({{ state.active.count.filter }})
>       </button>
>     </div>
>
>     <!-- Filter groups from shared state -->
>     <div
>       v-for="filter in state.available.filter"
>       :key="filter.key"
>       class="filter-group"
>     >
>       <h4>{{ filter.label }}</h4>
>       <label v-for="option in filter.options" :key="option.value">
>         <input
>           type="checkbox"
>           :checked="option.selected"
>           :disabled="option.disabled"
>           @change="toggleFilter(filter.key, option.value, option.selected)"
>         />
>         {{ option.option }} ({{ option.count }})
>       </label>
>     </div>
>   </aside>
> </template>
> ```
>
> > [!NOTE]
> >
> > **Why use a wrapper composable?**
> >
> > 1. **Centralized configuration** - All your filter options, sort labels, and search settings live in one file. Need to add a new filter? Update it once.
> >
> > 2. **Shared state** - The `cacheKey` option enables automatic state sharing. When multiple components call the same wrapper, they share the reactive `searchTerm`, filter/sort state, `result` data, and pagination.
> >
> > 3. **Clean components** - Your page and filter components stay focused on rendering, not configuration.
> >
> > Any action (like `addFilter`) called from one component automatically updates all other components using the same `cacheKey`.

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type { Ref, ShallowRef, MaybeRef } from "vue";
> import type {
>   ListingName,
>   ListingParamsOf,
>   ListingFiltersOf,
>   ListingSortsOf,
>   ResponseOf,
>   FilterOption,
>   AggregationGroup,
> } from "@frontic/nuxt/types";
>
> // Typed filter for UI rendering
> interface UiFilter<TFilters = Record<string, unknown>> {
>   key: keyof TFilters & string;
>   label: string;
>   options: FilterOption[];
>   type: string;
>   data?: AggregationGroup;
> }
>
> // A range accepted by `addFilter`, `filterResult` and `initialFilters`
> interface RangeFilterValue {
>   from?: number;
>   to?: number;
> }
>
> // A sort accepted by `sortResult`, `initialSort` and the `sorting` config.
> // `SortString<"ProductSearch">` is the same type looked up by listing name.
> type SortStringOf<TSorts> =
>   | "default"
>   | `${keyof TSorts & string}:${"asc" | "desc"}`;
>
> // Typed sort for UI rendering
> interface UiSort<TSorts = Record<string, unknown>> {
>   key: SortStringOf<TSorts>;
>   label: string;
>   value: SortStringOf<TSorts>;
> }
>
> // Fully typed search state
> interface SearchState<
>   TFilters = Record<string, unknown>,
>   TSorts = Record<string, unknown>,
> > {
>   active: {
>     filter: Partial<Record<keyof TFilters & string, unknown>>;
>     sorting:
>       | { field: keyof TSorts & string; order: "asc" | "desc" }
>       | undefined;
>     search: string;
>     page: number;
>     count: {
>       filter: number;
>       sorting: number;
>     };
>   };
>   available: {
>     filter: UiFilter<TFilters>[];
>     sorting: UiSort<TSorts>[];
>     nextPage: boolean;
>     prevPage: boolean;
>     count: {
>       filter: number;
>       sorting: number;
>       result: number;
>       page: number;
>     };
>   };
> }
>
> // `params` is optional when every field of ListingParamsOf<TSearch> is optional
> function useFronticSearch<
>   TSearch extends ListingName,
>   TFilters = ListingFiltersOf<TSearch>,
>   TSorts = ListingSortsOf<TSearch>,
> >(
>   name: TSearch,
>   params?: MaybeRef<ListingParamsOf<TSearch>>,
>   options?: {
>     contextKey?: MaybeRef<string | undefined>;
>     contextDomain?: MaybeRef<string | undefined>;
>     requestUrl?: MaybeRef<string | false | undefined>;
>     staleTime?: number;
>     gcTime?: number;
>     enabled?: MaybeRef<boolean> | (() => boolean);
>     keepPreviousData?: boolean;
>     initialData?: () => ResponseOf<TSearch> | undefined;
>     initialDataUpdatedAt?: number | (() => number);
>     cacheKey?: string;
>     searchTermThreshold?: number;
>     searchDebounce?: number;
>     analytics?: {
>       searchCommitMs?: number;
>       searchMinChars?: number;
>     };
>     orFilter?: Array<keyof TFilters & string>;
>     disableSearch?: boolean;
>     disableFilter?: boolean;
>     disableSorting?: boolean;
>     limit?: MaybeRef<number | undefined>;
>     infinite?: boolean;
>     initialPage?: number;
>     initialSearch?: string;
>     initialFilters?: Partial<Record<keyof TFilters & string, unknown>>;
>     initialSort?: SortStringOf<TSorts>;
>     urlQuerySync?:
>       | boolean
>       | {
>           prefix?: string; // Namespace for this instance's params
>           include?: Array<"search" | "sort" | "filter" | "page">;
>           params?: Record<string, string>; // Rename: slice name or filter field → param
>           history?: "replace" | "push";
>         };
>     filter?: {
>       select?: Array<keyof TFilters & string>;
>       mode?: "include" | "exclude";
>       label?: Partial<Record<keyof TFilters & string, string>>;
>       sort?: Array<keyof TFilters & string>; // Display order for filters
>       type?: Partial<Record<keyof TFilters & string, string>>; // Display type, or 'default'
>     };
>     sorting?: {
>       select?: Array<SortStringOf<TSorts>>;
>       mode?: "include" | "exclude";
>       label?: Partial<Record<SortStringOf<TSorts>, string>>;
>       sort?: Array<SortStringOf<TSorts>>; // Display order for sorts
>     };
>   },
> ): {
>   result: ShallowRef<ResponseOf<TSearch> | undefined>; // Full listing response
>   status: Ref<"pending" | "error" | "success">;
>   asyncStatus: Ref<"idle" | "loading">;
>   isLoading: Ref<boolean>;
>   error: ShallowRef<Error | null>;
>   state: Ref<SearchState<TFilters, TSorts>>; // Fully typed state
>   searchTerm: Ref<string>;
>   loadNext: () => Promise<ResponseOf<TSearch> | undefined>;
>   loadPrev: () => Promise<ResponseOf<TSearch> | undefined>;
>   loadPage: (page: number) => Promise<ResponseOf<TSearch> | undefined>;
>   refresh: () => Promise<void>;
>   refetch: () => Promise<void>;
>   sortResult: (sortBy?: SortStringOf<TSorts>) => Promise<void>;
>   resetSorting: () => Promise<void>;
>   filterResult: <K extends keyof TFilters>(
>     field: K,
>     values: Array<TFilters[K] | RangeFilterValue>,
>   ) => Promise<void>;
>   addFilter: <K extends keyof TFilters>(
>     field: K,
>     value: TFilters[K] | RangeFilterValue,
>   ) => Promise<void>;
>   removeFilter: <K extends keyof TFilters>(
>     field: K,
>     value?: TFilters[K],
>   ) => Promise<void>;
>   resetFilter: (field?: keyof TFilters & string) => Promise<void>;
>   resetSearch: () => Promise<void>;
>   reset: () => Promise<void>; // Reset all state (search, filters, sorting)
> };
> ```

#### Restoring state from the URL

A search page usually gets its term from the request - `?name=atomic` - not from someone typing. Pass it as `initialSearch` rather than assigning `searchTerm`:

```ts app/pages/search.vue theme={"theme":"css-variables"}
const route = useRoute()

const { result, state, searchTerm } = useFronticSearch('ProductSearch', {}, {
  initialSearch: route.query.name as string | undefined,
  initialFilters: { hersteller: route.query.hersteller ? [route.query.hersteller as string] : undefined },
  initialSort: route.query.sort as 'name:asc' | undefined,
  initialPage: Number(route.query.page) || 1,
})
```

> [!WARNING]
>
> Assigning `searchTerm` does not work for a term that arrives with the request.
> It commits through a debounced watcher, which never fires before the server
> render finishes - and after hydration the value has not changed, so the
> watcher never runs. The page renders unfiltered results and stays that way.
> `searchDebounce: 0` does not help.

The `initial*` options commit before the first query, so the server renders the right results and the browser does not refetch to correct them. They apply only when the state is empty, so a value restored from the SSR payload - or set by another component sharing the same `cacheKey` - wins over them.

`searchTerm` remains the right binding for an input the user types into.

***

### `useFronticTree`

Fetch a [Menu Tree](https://docs.frontic.com/api-builder/trees.md) - a hierarchical collection of records assembled from a Data Storage and rendered through a Detail Block - with automatic caching and SSR support. See [Caching](#caching) for details.

#### Parameters

**name** `TreeName` *required*

The name of the tree to fetch. Provides full autocomplete for your generated tree types.

**options** `object`

Configuration options.

**Tree Options**

**key** `string | Ref\<string | undefined\>`

Optional starting node key. When provided, the response contains the subtree rooted at this node. When omitted, all root-level nodes are returned.

**depth** `number | Ref\<number | undefined\>`

Optional level limit. Controls how many levels of `$items` are included. When omitted, all levels are returned (subject to the API's 1,000-node response cap).

**Request Options**

**contextKey** `string | false | Ref\<string\>`

Override the context token. Resolved from the context cookie when omitted -
see [The context token](#the-context-token). `false` sends none, so the
request resolves through the domain alone.

**contextDomain** `string | Ref\<string\>`

Context domain to override the global setting for this request.

**requestUrl** `string | false | Ref\<string | false\>`

Request URL recorded with the request, for analytics and attribution. Defaults to the current URL.

Pass `false` to send nothing. The URL differs per visitor, so it varies a request that is otherwise identical - enough to fragment a shared cache in front of the Fetch API. It is not part of the cache key either way, so switching it off changes nothing about how entries are stored.

**staleTime** `number` (default: 300000)

Cache duration in milliseconds. Default: 5 minutes.

**gcTime** `number` (default: 300000)

How long an unused cache entry is kept before it is garbage collected. An
entry no component is reading survives this long, so returning to the route
within the window renders from cache instead of refetching.

**enabled** `boolean | Ref\<boolean\> | (() =\> boolean)` (default: true)

Whether the query fetches. While `false` no request is made; flipping it back
to `true` refreshes. Use it when an input the query depends on is not known
yet, rather than calling the composable conditionally.

**keepPreviousData** `boolean` (default: true)

Keep the data already on screen while a changed key - a new page, filter or
context - loads, instead of dropping back to `pending` and blanking the view.
The held value is never written to the cache. First loads still report
`pending`; track the background load through `asyncStatus` or `isLoading`.

#### Returns

**tree** `Ref\<ResponseOf\<T\> | undefined\>`

The full tree response (`{ items?: [...] }`), fully typed based on the tree name.

**items** `ComputedRef\<TreeItems\<ResponseOf\<T\>\> | undefined\>`

Shortcut for `tree.value?.items`. Fully typed based on the tree name - when your generated client is in place, `items` resolves to the concrete node array, so iterating gives you autocomplete on `$items`, `key`, and the block fields defined by the tree's Detail Block (e.g. `name`, `link`).

**status** `Ref\<'pending' | 'error' | 'success'\>`

Current query status for loading states.

**asyncStatus** `Ref\<'idle' | 'loading'\>`

What the network is doing, independent of what the data is. Together with
`status` this separates a first load (`pending`) from a background refresh
(`success` while `asyncStatus` is `'loading'`) - the state `keepPreviousData`
produces.

**isLoading** `Ref\<boolean\>`

Shorthand for `asyncStatus === 'loading'`.

**error** `ShallowRef\<Error | null\>`

The error thrown by the last fetch - `null` while pending or after success. See [Error handling](#error-handling).

**refresh** `() =\> Promise\<void\>`

Refresh data using cache if still valid.

**refetch** `() =\> Promise\<void\>`

Force a fresh fetch, bypassing cache entirely.

#### Example

```ts theme={"theme":"css-variables"}
<script setup lang="ts">
const { items, status } = useFronticTree('CategoryNavigation', { depth: 2 })
</script>

<template>
  <nav v-if="status === 'success'">
    <ul>
      <li v-for="node in items" :key="node.key">
        <NuxtLink :to="node.link?.path">{{ node.name }}</NuxtLink>
        <ul v-if="node.$items.length">
          <li v-for="child in node.$items" :key="child.key">
            <NuxtLink :to="child.link?.path">{{ child.name }}</NuxtLink>
          </li>
        </ul>
      </li>
    </ul>
  </nav>
</template>
```

> [accordion] More Examples
>
> ```ts theme={"theme":"css-variables"}
> // Subtree rooted at a specific node
> const { items } = useFronticTree("CategoryNavigation", { key: "shop" });
>
> // Reactive starting key - refetches whenever the user expands a different branch
> const expandedKey = ref<string | undefined>(undefined);
> const { items } = useFronticTree("CategoryNavigation", {
>   key: expandedKey,
>   depth: 2,
> });
>
> // With custom cache duration
> const { tree } = useFronticTree("CategoryNavigation", {
>   staleTime: 1000 * 60 * 30, // 30 minutes
> });
> ```

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type { Ref, ShallowRef, ComputedRef, MaybeRef } from "vue";
> import type { TreeName, ResponseOf } from "@frontic/nuxt/types";
>
> // Internal helper - extracts the typed `items` array from a tree response
> type TreeItems<TResponse> = [TResponse] extends [{ items?: infer I }]
>   ? (unknown extends I ? unknown[] : Exclude<I, undefined>)
>   : unknown[];
>
> function useFronticTree<T extends TreeName>(
>   name: T,
>   options?: {
>     key?: MaybeRef<string | undefined>;
>     depth?: MaybeRef<number | undefined>;
>     contextKey?: MaybeRef<string | undefined>;
>     contextDomain?: MaybeRef<string | undefined>;
>     requestUrl?: MaybeRef<string | false | undefined>;
>     staleTime?: number;
>     gcTime?: number;
>     enabled?: MaybeRef<boolean> | (() => boolean);
>     keepPreviousData?: boolean;
>   },
> ): {
>   tree: Ref<ResponseOf<T> | undefined>;
>   items: ComputedRef<TreeItems<ResponseOf<T>> | undefined>;
>   status: Ref<"pending" | "error" | "success">;
>   asyncStatus: Ref<"idle" | "loading">;
>   isLoading: Ref<boolean>;
>   error: ShallowRef<Error | null>;
>   refresh: () => Promise<void>;
>   refetch: () => Promise<void>;
> };
> ```

> [!NOTE]
>
> The cache key includes `key`, `depth`, and `contextKey`, so different
> subtrees, depth caps, and contexts each cache independently - calling
> `useFronticTree('CategoryNavigation')` and
> `useFronticTree('CategoryNavigation', { key: 'shop' })` from different
> components will not collide.

***

### `useFronticPage`

Dynamic page routing with automatic slug detection, redirect handling, and 404 errors. Includes automatic caching and SSR support. See [Caching](#caching) for details.

#### Parameters

**slug** `string | Ref\<string\>`

The page slug. If omitted, auto-detected from current URL.

**options** `object`

Configuration options.

**Properties**

**redirectOn301** `boolean` (default: true)

Automatically redirect on 301 responses. Uses module config default.

**throwOn404** `boolean` (default: true)

Throw a 404 error when page is not found. Uses module config default.

**contextKey** `string | false | Ref\<string\>`

Override the context token. Resolved from the context cookie when omitted -
see [The context token](#the-context-token). `false` sends none, so the
request resolves through the domain alone.

**contextDomain** `string | Ref\<string\>`

Context domain to override the global setting for this request.

**requestUrl** `string | false | Ref\<string | false\>`

Request URL recorded with the request, for analytics and attribution. Defaults to the current URL.

Pass `false` to send nothing. The URL differs per visitor, so it varies a request that is otherwise identical - enough to fragment a shared cache in front of the Fetch API. It is not part of the cache key either way, so switching it off changes nothing about how entries are stored.

**staleTime** `number` (default: 300000)

Cache duration in milliseconds.

**gcTime** `number` (default: 300000)

How long an unused cache entry is kept before it is garbage collected. An
entry no component is reading survives this long, so returning to the route
within the window renders from cache instead of refetching.

**enabled** `boolean | Ref\<boolean\> | (() =\> boolean)` (default: true)

Whether the query fetches. While `false` no request is made; flipping it back
to `true` refreshes. Use it when an input the query depends on is not known
yet, rather than calling the composable conditionally.

**keepPreviousData** `boolean` (default: false)

Keep the previous page rendered while the next one resolves. Defaults to
`false` here, unlike every other composable: a changed key means a different
page, not the same one reloading, so holding the old one would show the
previous route's content - and put it in front of the redirect and 404
handling.

#### Returns

**page** `Ref\<Page | undefined\>`

The full page response. `Page` is your project's union of page types, discriminated by `type` - see [Typing page data](#typing-page-data).

**data** `ComputedRef\<PageData | undefined\>`

The page data payload for rendering - the union of every page type's `data`.

**type** `ComputedRef\<Page['type'] | undefined\>`

The page type for conditional rendering. Typed as your project's page types
(`'ProductCategory' | 'ProductDetail' | …`), not a plain string.

**block** `ComputedRef\<Page['block'] | undefined\>`

The block name to render for this page.

**route** `ComputedRef\<RouteMeta | undefined\>`

Route information including redirect and context data.

**RouteMeta Properties**

**code** `200 | 301 | 404`

HTTP status code.

**redirect** `PageRoute`

Redirect destination (`href`, `path`, `slug`, `url`), present on a 301.

**alternates** `AlternateRoute[]`

The same page in other locales and regions.

**context** `\{ region: string; locale: string; suggested?: AlternateRoute \}`

The region and locale the page resolved in, plus a suggested alternate when the visitor's context points elsewhere.

**alternates** `ComputedRef\<AlternateRoute[] | undefined\>`

Alternate language URLs for SEO hreflang tags.

**suggested** `ComputedRef\<AlternateRoute | undefined\>`

Suggested route when context changes (e.g., for locale switching).

**status** `Ref\<'pending' | 'error' | 'success'\>`

Current query status.

**asyncStatus** `Ref\<'idle' | 'loading'\>`

What the network is doing, independent of what the data is. Together with
`status` this separates a first load (`pending`) from a background refresh
(`success` while `asyncStatus` is `'loading'`) - the state `keepPreviousData`
produces.

**isLoading** `Ref\<boolean\>`

Shorthand for `asyncStatus === 'loading'`.

**error** `ShallowRef\<Error | null\>`

The error thrown by the last fetch - `null` while pending or after success. See [Error handling](#error-handling).

**refresh** `() =\> Promise\<void\>`

Refresh using cache if valid.

**refetch** `() =\> Promise\<void\>`

Force fresh fetch.

#### Example

```ts pages/[...slug].vue theme={"theme":"css-variables"}
<script setup lang="ts">
const { data, block, alternates, status } = useFronticPage()

useHead({
  link:
    alternates.value?.map((alt) => ({
      rel: 'alternate',
      hreflang: alt.lang ?? alt.locale,
      href: alt.href,
    })) ?? [],
})
</script>

<template>
  <div v-if="status === 'pending'">Loading...</div>
  <component v-else :is="resolveComponent(block)" :data="data" />
</template>
```

> [accordion] More Examples
>
> ```ts theme={"theme":"css-variables"}
> // With explicit slug
> const { page } = useFronticPage("demo-shop.com/uk/products/shoes");
>
> // Disable redirect for manual handling
> const { page, route } = useFronticPage(undefined, { redirectOn301: false });
>
> if (route.value?.redirect) {
>   navigateTo(route.value.redirect.path);
> }
>
> // Disable 404 throwing for custom error handling
> const { page, route } = useFronticPage(undefined, { throwOn404: false });
>
> if (route.value?.code === 404) {
>   // Custom 404 handling
> }
> ```

#### Typing page data

`Page` is the union of your project's page types, generated by the CLI. Narrow on `page.value.type` and `page.value.data` gets the shape of that page type - no cast needed:

```ts pages/[...slug].vue theme={"theme":"css-variables"}
const { page } = useFronticPage();

if (page.value?.type === "ProductDetail") {
  page.value.data; // typed as the ProductDetail page's data
}
```

Narrow on `page`, not on the destructured refs. `type` and `data` are separate computed refs, so checking `type.value` does not narrow `data.value`.

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type { Ref, ShallowRef, ComputedRef, MaybeRef } from "vue";
> // All of these are importable from "@frontic/nuxt/types"
> import type { Page, PageData } from "@frontic/nuxt/types";
> // Page: your project's page union, discriminated by `type`
> // PageData: the union of every page type's `data`
>
> interface PageRoute {
>   href: string;
>   path: string;
>   slug: string;
>   url: string;
> }
>
> interface RouteMeta {
>   code: 200 | 301 | 404;
>   redirect?: PageRoute;
>   alternates?: AlternateRoute[];
>   context?: {
>     region: string;
>     locale: string;
>     suggested?: AlternateRoute;
>   };
> }
>
> interface AlternateRoute extends PageRoute {
>   locale: string;
>   region: string;
>   lang?: string;
> }
>
> function useFronticPage(
>   slug?: MaybeRef<string>,
>   options?: {
>     redirectOn301?: boolean;
>     throwOn404?: boolean;
>     contextKey?: MaybeRef<string | undefined>;
>     contextDomain?: MaybeRef<string | undefined>;
>     requestUrl?: MaybeRef<string | false | undefined>;
>     staleTime?: number;
>     gcTime?: number;
>     enabled?: MaybeRef<boolean> | (() => boolean);
>     keepPreviousData?: boolean;
>   },
> ): {
>   page: Ref<Page | undefined>;
>   data: ComputedRef<PageData | undefined>;
>   type: ComputedRef<Page["type"] | undefined>;
>   block: ComputedRef<Page["block"] | undefined>;
>   route: ComputedRef<RouteMeta | undefined>;
>   alternates: ComputedRef<AlternateRoute[] | undefined>;
>   suggested: ComputedRef<AlternateRoute | undefined>;
>   status: Ref<"pending" | "error" | "success">;
>   asyncStatus: Ref<"idle" | "loading">;
>   isLoading: Ref<boolean>;
>   error: ShallowRef<Error | null>;
>   refresh: () => Promise<void>;
>   refetch: () => Promise<void>;
> };
> ```

> [!NOTE]
>
> The composable automatically constructs the page slug from the current request
> URL (host + pathname). This works correctly in both SSR and client-side
> navigation.

***

### `useFronticContext`

Manage locale and region switching with cookie persistence.

Every call site shares one context. `contexts`, `current`, `token` and `isLoading` are app-wide state, so a header switcher and a footer switcher always agree, and the context is fetched once per app rather than once per component. A call from a plugin, outside any component, fetches too. Each change fires the [context hooks](#context-changes), so code outside components can follow it without holding a ref.

#### Parameters

**options** `object`

Configuration options.

**Properties**

**disableContext** `boolean` (default: false)

Disable automatic context fetching and cookie management. When `true`, you
must manually call `refresh()` to fetch contexts and manage the token
yourself. Overrides the module-level `disableContext` setting.

**cookieName** `string` (default: fs-context)

Cookie name for persisting the context token. Calls with the same cookie
name share state; a different name keeps its own `contexts`, `current` and
`token`, but composables still read the first cookie - see [The context
token](#the-context-token).

**cookieMaxAge** `number` (default: 31536000)

Cookie max age in seconds. Default: 1 year.

#### Returns

**contexts** `Readonly\<Ref\<ContextOption[]\>\>`

Available context options with regions and locales.

**ContextOption Properties**

**region** `string`

Region code (e.g., `'uk'`, `'de'`).

**currency** `string`

Currency code (e.g., `'GBP'`, `'EUR'`).

**locales** `Array\<\{ key: string; url: string \}\>`

Available locales with their URLs.

**current** `Readonly\<Ref\<Context | null\>\>`

Current active context.

**Context Properties**

**region** `string`

Current region (e.g., `'uk'`).

**locale** `string`

Current locale (e.g., `'en-gb'`).

**scope** `string`

Current scope (e.g., `'b2c'`).

**token** `string`

Context identifier token.

**token** `Readonly\<Ref\<string | null\>\>`

The current context token.

**update** `(context: \{ region: string; locale: string \}) =\> Promise\<void\>`

Switch to a different region/locale combination. When the switch hands back a
new token, it is written to `token` and the cookie, so later requests carry it.

**isLoading** `Readonly\<Ref\<boolean\>\>`

Whether the context itself is resolving. Content that depends on it refetches
on its own - the token is part of every query key, so a switch re-keys each
query - and every composable reports that through its own `asyncStatus`.

**refresh** `() =\> Promise\<void\>`

Fetch the available contexts and the current context again. A call while a
fetch is already running joins it instead of starting a second one.

#### Example

```ts theme={"theme":"css-variables"}
<script setup lang="ts">
const { contexts, current, update, isLoading } = useFronticContext()

function getDefaultLocale(region: string) {
  return contexts.value.find((c) => c.region === region)?.locales[0]?.key ?? 'en'
}
</script>

<template>
  <select
    :value="current?.region"
    :disabled="isLoading"
    @change="
      update({
        region: $event.target.value,
        locale: getDefaultLocale($event.target.value),
      })
    "
  >
    <option v-for="ctx in contexts" :key="ctx.region" :value="ctx.region">{{ ctx.region.toUpperCase() }} ({{ ctx.currency }})</option>
  </select>
</template>
```

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type { Ref, Readonly } from "vue";
>
> interface Context {
>   region: string;
>   locale: string;
>   scope: string;
>   token: string;
> }
>
> interface ContextOption {
>   region: string;
>   currency: string;
>   locales: Array<{ key: string; url: string }>;
> }
>
> function useFronticContext(options?: {
>   cookieName?: string;
>   cookieMaxAge?: number;
>   disableContext?: boolean;
> }): {
>   contexts: Readonly<Ref<ContextOption[]>>;
>   current: Readonly<Ref<Context | null>>;
>   token: Readonly<Ref<string | null>>;
>   update: (context: { region: string; locale: string }) => Promise<void>;
>   isLoading: Readonly<Ref<boolean>>;
>   refresh: () => Promise<void>;
> };
> ```

***

### `useFronticClient`

Low-level client for direct API access. All other composables use this internally.

#### Parameters

**options** `object`

Configuration options for client behavior.

**Properties**

**proxy** `boolean | string`

Override proxy behavior: `true` (force proxy), `false` (direct API), or
custom path.

**contextDomain** `false | string`

Override context domain: `false` (disable) or specific domain.

**contextKey** `false | string`

Override the context token: `false` (send none) or a specific token.
Resolved from the context cookie when omitted.

**requestUrl** `false | string`

Override request URL tracking: `false` (disable) or specific URL.

**secret** `string`

API secret for server-side requests.

#### Returns

Returns a `FronticClient` instance with type-safe methods:

**block** `\<T\>(name, key, config?) =\> Promise\<ResponseOf\<T\>\>`

Fetch a block by name and key.

**listing** `\<T\>(name, params, config?) =\> Promise\<ResponseOf\<T\>\>`

Fetch a listing with parameters and query options.

**tree** `\<T\>(name, config?) =\> Promise\<ResponseOf\<T\>\>`

Fetch a menu tree by name. Pass `key` and/or `depth` under `config.query` to fetch a subtree or cap the depth.

**page** `(slug, config?) =\> Promise\<Page\>`

Fetch a page by its slug.

**context** `(token, config?) =\> Promise\<Context\>`

Get context by token.

**contextList** `(token?, config?) =\> Promise\<[ContextOption[], string]\>`

Get available contexts.

**contextUpdate** `(context, token, config?) =\> Promise\<Context\>`

Update context with new region/locale.

**sitemap** `(domain, index?, config?) =\> Promise\<string\>`

Fetch the sitemap XML for a Frontic domain. See [Sitemap](#sitemap).

#### Example

```ts theme={"theme":"css-variables"}
const client = useFronticClient();

// Fetch a product block
const product = await client.block("ProductFull", "product-123");

// Fetch a listing with filters
const listing = await client.listing(
  "CategoryProducts",
  { key: "shoes" },
  {
    query: {
      filter: [{ type: "equals", field: "properties.color", value: "Red" }],
      sort: { field: "price.amount", order: "asc" },
      limit: 20,
    },
  },
);
```

> [accordion] More Examples
>
> ```ts theme={"theme":"css-variables"}
> // Fetch page data
> const page = await client.page("demo-shop.com/uk/products");
>
> // Client with custom configuration
> const directClient = useFronticClient({ proxy: false });
> const customProxyClient = useFronticClient({ proxy: "/api/custom-frontic" });
> ```

> [accordion] Type Signature
>
> ```ts theme={"theme":"css-variables"}
> import type {
>   BlockName,
>   ListingName,
>   TreeName,
>   ListingParamsOf,
>   ListingQuery,
>   ResponseOf,
>   TreeQuery,
>   Page,
>   Context,
>   ContextOption,
> } from "@frontic/nuxt/types";
>
> interface RequestOptions {
>   requestUrl?: string | false; // false: send none
>   contextKey?: string | false; // false: send none
>   contextDomain?: string;
>   proxyUrl?: string;
>   secret?: string; // Sent as fs-secret - server-side only
> }
>
> interface FronticClient {
>   block: <T extends BlockName>(
>     name: T,
>     key: string,
>     config?: RequestOptions,
>   ) => Promise<ResponseOf<T>>;
>   listing: <T extends ListingName>(
>     name: T,
>     parameters: ListingParamsOf<T>,
>     config?: { query?: ListingQuery<T> } & RequestOptions,
>   ) => Promise<ResponseOf<T>>;
>   tree: <T extends TreeName>(
>     name: T,
>     config?: { query?: TreeQuery } & RequestOptions,
>   ) => Promise<ResponseOf<T>>;
>   page: (slug: string, config?: RequestOptions) => Promise<Page>;
>   context: (token: string, config?: RequestOptions) => Promise<Context>;
>   contextList: (
>     token?: string,
>     config?: RequestOptions,
>   ) => Promise<[ContextOption[], string]>;
>   contextUpdate: (
>     context: { region: string; locale: string },
>     token: string,
>     config?: RequestOptions,
>   ) => Promise<Context>;
>   sitemap: (
>     domain: string,
>     index?: number | null,
>     config?: RequestOptions,
>   ) => Promise<string>;
> }
>
> function useFronticClient(options?: {
>   proxy?: boolean | string;
>   contextDomain?: false | string;
>   contextKey?: false | string;
>   requestUrl?: false | string;
>   secret?: string;
> }): FronticClient;
> ```

***

## Building blocks

The composables are assembled from smaller pieces the module also auto-imports: a query factory and key factory per data type, and the filter and sort helpers behind `useFronticSearch`. Reach for them when a composable does not fit - invalidating cached responses after something changed, running a Frontic query through your own `useQuery` or `useAsyncData`, or building filter UI without `useFronticSearch`.

Each group is registered only alongside the composable it belongs to, so turning a composable off with the [`composables`](#configuration) option removes its building blocks too:

| Registered with | Names |
| - | - |
| `useFronticBlock` | `fetchBlockQuery`, `BLOCK_QUERY_KEYS`, `createBlockCacheKey` |
| `useFronticListing` or `useFronticSearch` | `fetchListingQuery`, `LISTING_QUERY_KEYS`, `createCacheKey` |
| `useFronticTree` | `fetchTreeQuery`, `TREE_QUERY_KEYS`, `createTreeCacheKey` |
| `useFronticPage` | `fetchPageQuery`, `PAGE_QUERY_KEYS`, `createPageCacheKey` |
| `useFronticSearch` | the [filter and sort helpers](#filter-and-sort-helpers) |

### Query keys

Every cached response lives under a key built by one of four key factories. Each has the same three levels, from broadest to most specific:

| Factory | `root` | One block, listing, tree or page | `detail(...)` |
| - | - | - | - |
| `BLOCK_QUERY_KEYS` | `['blocks']` | `block(name)` | `detail(name, key, contextKey?, contextDomain?)` |
| `LISTING_QUERY_KEYS` | `['listings']` | `listing(name)` | `detail(name, parameters, query, contextKey?, contextDomain?)` |
| `TREE_QUERY_KEYS` | `['trees']` | `tree(name)` | `detail(name, key?, depth?, contextKey?, contextDomain?)` |
| `PAGE_QUERY_KEYS` | `['pages']` | `page(slug)` | `detail(slug, contextKey?, contextDomain?)` |

A `detail` key always ends in `{ context, domain }`, with `null` for whichever is not set - this is the [context slot](#what-a-cache-entry-is-keyed-by) every composable keys on. A tree key without a node key holds `''` in that slot, and one without a depth holds `'all'`.

The broader levels are prefixes of every `detail` key under them, which makes them the reliable way to reach the composables' cache entries without rebuilding a key exactly:

```ts theme={"theme":"css-variables"}
import { useQueryCache } from "@pinia/colada";

const queryCache = useQueryCache();

// Every ProductSearch response, whatever its filters, page or context
await queryCache.invalidateQueries({ key: LISTING_QUERY_KEYS.listing("ProductSearch") });

// Every cached block
await queryCache.invalidateQueries({ key: BLOCK_QUERY_KEYS.root });
```

`useFronticSearch` in infinite mode keeps its accumulated pages as one entry under the same `listing(name)` prefix, so the example above reaches it too.

### Query factories

`fetchBlockQuery`, `fetchListingQuery`, `fetchTreeQuery` and `fetchPageQuery` return the query options the matching composable hands to Pinia Colada, so a query you build with them caches, deduplicates and hydrates exactly like the composable's:

```ts theme={"theme":"css-variables"}
import { useQuery } from "@pinia/colada";

const { token } = useFronticContext();

const { data, status } = useQuery(
  fetchListingQuery({
    name: "ProductSearch",
    params: {},
    query: { limit: 4, sort: [{ field: "price.amount", order: "asc" }] },
    contextKey: computed(() => token.value ?? undefined),
  }),
);
```

| Factory | Required | Optional |
| - | - | - |
| `fetchBlockQuery` | `name`, `key` | |
| `fetchListingQuery` | `name`, `params` | `query`, `page`, `initialData`, `initialDataUpdatedAt` |
| `fetchTreeQuery` | `name` | `key`, `depth` |
| `fetchPageQuery` | `slug` | |

Every factory also takes `contextKey`, `contextDomain` and `requestUrl`, and the cache options `staleTime` (default 5 minutes), `gcTime`, `enabled` and `keepPreviousData` (default `true`, including for `fetchPageQuery`, where `useFronticPage` defaults it to `false`). They mean what they mean on the composables. `key`, `params`, `query`, `page`, `depth`, `slug`, the three context options and `enabled` may be refs. `fetchBlockQuery` stays disabled while `key` is empty, on top of any `enabled` you pass. A `page` is merged into the listing query and its key only when it is set.

The returned object has `key` (a getter for the `detail` key), `query` (the fetch), and the resolved cache options. Spread it to override one of them:

```ts theme={"theme":"css-variables"}
useQuery({ ...fetchPageQuery({ slug: "demo-shop.com/uk" }), staleTime: 0 });
```

> [!WARNING]
>
> **Pass the context explicitly.** A composable resolves the visitor's context token and the configured domain, and builds both the key and the request from them. A factory keys on exactly what you pass, but its request goes through a `useFronticClient()` that still resolves the token and the domain itself. Leave `contextKey` out, and the key says `context: null` while the request carries the visitor's token - so a visitor who switches context keeps being served the old entry. Pass the token from [`useFronticContext`](#usefronticcontext) as above, and your domain as `contextDomain` when the module sends one.

The factories create that client on first fetch, so they belong where a Nuxt app is running: component setup, your own composables, plugins. A Nitro route has none - use [`createFronticServerClient`](#server-routes) there.

#### With `useAsyncData`

`useAsyncData` takes a string key. `createBlockCacheKey`, `createCacheKey` (listings), `createTreeCacheKey` and `createPageCacheKey` turn a key array into one, joining its parts with `-` and writing object parts as JSON. All four do the same thing; use the one named for the data type.

```ts theme={"theme":"css-variables"}
const tree = fetchTreeQuery({ name: "CategoryNavigation", depth: 2 });

const { data } = await useAsyncData(createTreeCacheKey(tree.key()), tree.query);
// key: 'trees-CategoryNavigation--2-{"context":null,"domain":null}'
```

The string is computed once, from the key at the time of the call, and JSON keeps property order - two query objects with the same fields in a different order give two strings, where Pinia Colada would see one entry. Nothing here touches the composables' cache: `useAsyncData` keeps its own.

### Filter and sort helpers

The pure functions `useFronticSearch` uses to build its query and its `state`. They take plain values and return new ones without changing their input, so they also work in your own filter UI, in tests, or to prepare `initialFilters`.

#### Building filters

These work on a list of equals filters - `{ type: 'equals', field, value }`, one per field.

| Helper | Returns |
| - | - |
| `makeEqualsFilter(field, value)` | `{ type: 'equals', field, value }`. `value` is one value or an array. |
| `makeLogicalFilter(type, filters)` | `[{ type, filter: filters }]` for `type` `'and'` or `'or'`, or `[]` when `filters` is empty - so it can be spread into a filter array unconditionally. |
| `manageFilter(list, field, value)` | The list with `field`'s filter replaced by one for `value`, or with that filter added when the field had none. |
| `removeFilter(list, field, option?)` | Without `option`, the list without `field`'s filter. With it, `field`'s filter without that value; a filter left with no values is dropped. |
| `hasFilter(list, field)` | Whether `field` has a filter in the list. |
| `getFilterValues(list, field)` | `field`'s values as an array, `[]` when it has no filter. |

```ts theme={"theme":"css-variables"}
let filters = manageFilter([], "properties.color", ["red", "blue"]);
filters = removeFilter(filters, "properties.color", "red");
// [{ type: 'equals', field: 'properties.color', value: ['blue'] }]

const query = { filter: makeLogicalFilter("or", filters) };
// { filter: [{ type: 'or', filter: [...] }] }
```

> [!NOTE]
>
> The standalone `removeFilter` treats a falsy `option` as no option: `removeFilter(list, "rating", 0)` removes the whole `rating` filter, not just `0`. Values are compared with `===`. The `removeFilter` that `useFronticSearch` returns is a different function - it updates the search and refetches.

#### Reading a listing response

These turn the `filter` and `aggregation` of a listing response into UI state. `config` has the shape of `useFronticSearch`'s [`filter` and `sorting` options](#usefronticsearch): `select`, `mode`, `label`, `sort` and `type`.

| Helper | Returns |
| - | - |
| `processAvailableFilters(filter, aggregation, config?)` | One entry per field - `{ key, label, type, options, data? }` - which is what `state.available.filter` holds. Fields with no options and no aggregation statistics are left out; `data` is the aggregation, when there is one. |
| `makeFilterState(filter)` | The selected options per field: `{ [field]: FilterOption[] }`, leaving out fields with nothing selected. |
| `applyFilterConfig(state, config?)` | That state narrowed by `select` and `mode`, with each field renamed to its `label` when it has one. |
| `countActiveFilters(state)` | The number of fields in the state, `0` for `undefined`. |
| `processAvailableSorts(config?, responseHasSort?)` | One `{ key, label, value }` per entry of `config.label`, narrowed by `select` and ordered by `sort`. A sort without a label is not listed. Returns `[]` when `responseHasSort` is `false`. |

```ts theme={"theme":"css-variables"}
const filters = processAvailableFilters(response.filter, response.aggregation, {
  select: ["properties.color", "price.amount"],
  label: { "properties.color": "Color", "price.amount": "Price" },
  type: { "price.amount": "range" },
});
// [{ key: 'properties.color', label: 'Color', type: 'default', options: [...] },
//  { key: 'price.amount', label: 'Price', type: 'range', options: [], data: { ... } }]
```

#### Sort strings

Sorts travel as `'field:asc'`, `'field:desc'` or `'default'`, as in `sortResult`.

| Helper | Returns |
| - | - |
| `parseSortString(sort)` | `{ field, order }`, or `undefined` for `'default'`. It splits on `:` and checks nothing else. |
| `isSortAllowed(sort, config?)` | Whether `config.select` and `config.mode` let `sort` through. With no `select`, every sort is allowed - including a malformed one. |

```ts theme={"theme":"css-variables"}
parseSortString("price.amount:desc"); // { field: 'price.amount', order: 'desc' }
isSortAllowed("name:asc", { select: ["price.amount:asc"] }); // false
```

Neither validates a sort string from untrusted input such as the URL; check it against the sorts you offer before applying it.

***

## Server routes

`useFronticClient` is a Nuxt app composable - it reads `useNuxtApp`, `useRequestURL` and the app's runtime config, none of which exist in Nitro. A `server/` route uses `createFronticServerClient(event)` instead:

```ts server/api/stock.get.ts theme={"theme":"css-variables"}
import { createFronticServerClient } from "@frontic/nuxt/server";

export default defineEventHandler(async (event) => {
  const client = createFronticServerClient(event);

  return await client.listing("ProductSearch", {}, { query: { limit: 5 } });
});
```

It returns the same `FronticClient` the composables use, and resolves from the incoming request what the app-side client resolves from the Nuxt app:

| Resolved | From |
| - | - |
| `contextDomain` | The module's configured `contextDomain` |
| `contextKey` | The request's context cookie |
| `requestUrl` | The URL of the incoming request |
| `fs-secret` | Attached by the module's Nitro plugin - nothing to wire |

Failures are announced on the Nitro `frontic:fetch:error` hook, with the payload the app-side hook carries - see [Error reporting](#error-reporting).

#### Parameters

**event** `H3Event` *required*

The request being handled. Its cookies and URL are what the client resolves from.

> [!WARNING]
>
> There is no cache. This is a client, not a query: nothing is deduplicated,
> nothing is stored, and nothing reaches the SSR payload. For data a page
> renders, use the composables; for a route's own response, cache it with
> Nitro's own tools.

**options** `object`

Overrides for what the event would otherwise decide.

**Properties**

**contextDomain** `false | string`

Override the domain: `false` (send none) or a specific domain.

**contextKey** `false | string`

Override the token: `false` (send none, resolving through the domain
alone) or a specific token.

**requestUrl** `false | string`

Override the recorded URL: `false` (send none) or a specific URL.

> [!NOTE]
>
> `contextDomain: true` reads the active `@nuxtjs/i18n` locale, and there is no
> i18n instance in Nitro. A project configured that way resolves no domain here
> rather than guessing one - pass `contextDomain` per call, or address pages by
> a slug that already carries the domain.

***

## Hooks

The module fires Nuxt hooks as it works, so you can instrument a storefront without wiring anything per call site. Register them in a plugin.

```ts app/plugins/frontic.ts theme={"theme":"css-variables"}
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('frontic:listing:resolved', (payload) => {
    if (payload.trigger === 'initial') {
      track('view_item_list', { list: payload.listing, count: payload.resultCount })
    }
  })
})
```

Every hook name and payload is typed, so `payload.trigger` autocompletes and a misspelled hook name is a compile error. The listing and search hooks are typed only while `useFronticSearch` is enabled, and the page hook only while `useFronticPage` is - see the `composables` [option](#configuration).

| Hook | Fires when |
| - | - |
| `frontic:context:ready` | The region and locale context first resolves |
| `frontic:context:updated` | The context first resolves, and whenever it changes |
| `frontic:listing:resolved` | A listing settles, with what the shopper did |
| `frontic:search:committed` | A search term settles into intent |
| `frontic:page:resolved` | A page resolves to an entity |
| `frontic:fetch:error` | Any Frontic call fails |

To move a handler out of the plugin, type it with the payload types from `@frontic/nuxt/types`:

```ts app/utils/track-listing.ts theme={"theme":"css-variables"}
import type { FronticListingResolved } from '@frontic/nuxt/types'

export function trackListing(payload: FronticListingResolved) {
  // …
}
```

The analytics payloads are `FronticListingResolved`, `FronticSearchCommitted` and `FronticPageResolved`, with `FronticAnalyticsItem` and `FronticListingTrigger` for their parts. The context hooks pass a `FronticContext`.

Every analytics payload carries a `version`, currently `1`. It changes only when a payload's shape breaks - added fields don't bump it - so a handler can check it before reading fields. Give `switch (payload.trigger)` a `default` branch, so a trigger added later doesn't fall through silently.

### Listing and search

`frontic:listing:resolved` fires from `useFronticSearch` and carries a `trigger` telling you *why* the listing changed. The trigger comes from the method you called, never from the data:

| Trigger | Caused by |
| - | - |
| `initial` | The listing's first result on a page view |
| `term` | The search input settling, or `resetSearch` |
| `filter` | `addFilter`, `removeFilter`, `filterResult`, `resetFilter` |
| `sort` | `sortResult`, `resetSorting` |
| `page` | `loadPage`, and `loadNext`/`loadPrev` without `infinite` |
| `loadMore` | `loadNext`/`loadPrev` with `infinite: true` |
| `reset` | `reset` |
| `url` | A back/forward navigation or edited link, with [`urlQuerySync`](#restoring-state-from-the-url) on |

That distinction is only available inside the module. From outside you can see that data changed, but not whether a shopper applied a filter or a cache revalidated - so listing analytics built on reactive state count both.

Cache revalidations never fire the hook, and one shopper action produces one event however many components read the same listing. `initial` fires once per `cacheKey` per page view - navigating back to a listing counts as a new view.

| Field | Type | Description |
| - | - | - |
| `listing` | `string` | Listing name |
| `cacheKey` | `string` | The search's [`cacheKey`](#usefronticsearch) |
| `trigger` | `FronticListingTrigger` | Why the listing changed |
| `term` | `string?` | Active search term, when there is one |
| `filters` | `Record<string, unknown>` | Active filters by field |
| `changedFilter` | `{ field, value?, action }?` | The filter that changed, for `filter` triggers. `action` is `'add'` or `'remove'`. Absent when `resetFilter()` clears every field |
| `sort` | `{ field, order }?` | Active sort, when not the default |
| `page` | `number` | Current page |
| `resultCount` | `number` | Items in this result |
| `totalCount` | `number` | Items across all pages |
| `items` | `FronticAnalyticsItem[]` | `{ key, index, data }` per item |
| `version` | `number` | Payload version |

Each event describes the page that just resolved: `page` is its number, `resultCount` its item count, and `items` its rows. `totalCount` is the overall total. `index` is each item's absolute position in the full result set - the first item on page 2 of a 24-item page is `24`, not `0`:

```ts app/plugins/frontic.ts theme={"theme":"css-variables"}
nuxtApp.hook('frontic:listing:resolved', (payload) => {
  for (const item of payload.items) {
    const row = item.data as ProductCard
    track('view_item', { id: item.key, position: item.index, price: row.price })
  }
})
```

Prices in `data` are integers in minor units - see [Price](https://docs.frontic.com/reference/storage-field-types.md#price).

This holds with `infinite: true` as well: `loadNext` and `loadPrev` each report the page they fetched, not the accumulated list, and a `loadNext` past the last page emits nothing. Adding the events up therefore gives exactly what the shopper saw, which is what an impression tracker needs.

`frontic:search:committed` fires only once a term stops changing. Instrumenting off the search input directly records `s`, `sk`, `ski`, `skis` as four searches, which turns your top-queries report into a list of prefixes:

```ts app/pages/search.vue theme={"theme":"css-variables"}
const { result } = useFronticSearch('ProductSearch', params, {
  searchDebounce: 200, // how fast results update
  analytics: {
    searchCommitMs: 1000, // how long a term must rest to count
    searchMinChars: 3, // shortest term worth recording
  },
})
```

It carries `term` (trimmed), `cacheKey`, `resultCount`, `page` and `version`. The term counts once it is at least `searchMinChars` long, whether or not it reached `searchTermThreshold` and triggered a request.

> [!NOTE]
>
> These knobs are separate from `searchDebounce`, which controls how fast
> results update. The two answer different questions and should not share a
> value.

### Page

`frontic:page:resolved` fires when `useFronticPage` resolves to an entity. Redirects and 404s don't fire it. The payload is `{ page, version }`, where `page` is your project's `Page` union - narrow on `page.type` to read its data, as in [Typing page data](#typing-page-data):

```ts app/plugins/frontic.ts theme={"theme":"css-variables"}
nuxtApp.hook('frontic:page:resolved', ({ page }) => {
  track('page_view', { type: page.type })
})
```

It fires once per page per page view, so several components reading the same page produce one event.

### Context changes

`frontic:context:ready` fires once, when the context first resolves. `frontic:context:updated` fires at the same moment and again on every change after it, so a single `updated` listener sees every context the visitor has. A refresh that resolves to the same region, locale and scope is not announced again, and a listener registered after the context resolved still receives it.

```ts app/plugins/analytics.ts theme={"theme":"css-variables"}
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('frontic:context:updated', (context) => {
    analytics.setSessionProperties({
      region: context.region,
      locale: context.locale,
      scope: context.scope,
    })
  })
})
```

The payload carries `region`, `locale`, `scope` and `token`. Its type is `FronticContext` from `@frontic/nuxt/types`.

### Error reporting

`frontic:fetch:error` fires wherever a Frontic call fails - composables, `useFronticClient`, [`createFronticServerClient`](#server-routes) and the proxy route.

> [!WARNING]
>
> This hook fires on two runtimes. Composable and SSR calls run in the Nuxt app;
> the proxy route and `createFronticServerClient` run in Nitro. To catch every
> failure, register in both.

```ts app/plugins/frontic-errors.ts theme={"theme":"css-variables"}
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('frontic:fetch:error', (payload) => {
    Sentry.captureException(payload.error, { contexts: { frontic: payload } })
  })
})
```

```ts server/plugins/frontic-errors.ts theme={"theme":"css-variables"}
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('frontic:fetch:error', (payload) => {
    Sentry.captureException(payload.error, { contexts: { frontic: payload } })
  })
})
```

| Field | Type | Description |
| - | - | - |
| `operation` | `'block' \| 'listing' \| 'tree' \| 'page' \| 'context' \| 'sitemap' \| 'proxy'` | Which call failed. Every context call reports as `context` |
| `name` | `string?` | Block, listing or tree name |
| `slug` | `string?` | Page slug, for `page` |
| `domain` | `string?` | Frontic domain, for `sitemap` |
| `contextDomain` | `string?` | Context domain the call was made under |
| `contextKey` | `string?` | Context token the call was made under |
| `requestUrl` | `string?` | Page URL the call came from |
| `proxyUrl` | `string?` | Proxy path, when the call went through one |
| `targetUrl` | `string?` | Upstream URL, for `proxy` |
| `status` | `number?` | HTTP status. Absent when no response arrived |
| `chain` | `string[]` | The error's `cause` chain, outermost first |
| `message` | `string` | The chain as one line, for logs |
| `error` | `unknown` | The original error, for your error tracker |
| `version` | `number` | Payload version |

The `chain` is there because connection failures arrive as a bare `Bad Gateway`, with the real reason in `cause`:

```ts theme={"theme":"css-variables"}
['Error: Bad Gateway', 'Error: fetch failed', 'Error(ECONNRESET): read ECONNRESET']
```

`cause` is non-enumerable, so it never appears in `console.error` or in anything that serialises the error - without the chain, every upstream failure looks identical.

The payload type is `FronticFetchErrorPayload` from `@frontic/nuxt/types`.

Reporting never changes control flow. A failed composable or client call still rejects with the original error, so you can never mistake a failure for an empty result. The proxy answers the browser with its own `502`, `504` or `400` error, carrying the original as `cause`. `useFronticContext` logs a failed context call instead of rejecting.

> [!NOTE]
>
> The listing, search and page hooks are client-only, so you never double-count
> on hydration. `frontic:fetch:error` fires during SSR too - a request that dies
> while rendering is exactly the failure worth reporting.

***

## Error handling

A composable that fails sets its `error` ref and `status: 'error'`. The Fetch API's [error body](https://docs.frontic.com/reference/fetch-api.md#errors) is on the error's `data`. Use the auto-imported `isApiError` to check it before reading its fields:

```ts theme={"theme":"css-variables"}
const { error } = useFronticBlock('ProductCard', key)

const body = computed(() => (error.value as { data?: unknown } | null)?.data)

if (isApiError(body.value) && body.value.code === 'KEY_NOT_FOUND') {
  // body.value.code and body.value.correlationId are typed here
}
```

`isApiError(value)` returns `true` when `value` has both `code` and `correlationId` - a deliberate error, such as a missing record or a failed validation. An unexpected server error (`500`) carries no `code`, so it returns `false`. `error` is typed as a plain `Error`, which is why the example casts before reading `data`.

The body types come from `@frontic/nuxt/types`:

| Type | Shape |
| - | - |
| `ApiError` | `{ error: string; code: <one of the error codes>; details: { message: string; propertyPath: string; type?: 'error' \| 'soft_error' }[] \| { limit: number }; correlationId: string }` |
| `InternalError` | `{ error: string; correlationId: string }` |
| `FronticApiError` | `ApiError \| InternalError` |

These shapes come from your project's spec when you run `frontic generate`. `code` is the closed set of [error codes](https://docs.frontic.com/reference/fetch-api.md#errors) the Fetch API returns, so comparing against a code that doesn't exist fails type-checking. `details` depends on the code:

* `INVALID_PARAMETERS` and `DATA_VIOLATION` - an array of violations, each with a `message` and the `propertyPath` it applies to. `type` is set only on `DATA_VIOLATION`.
* `FETCH_API_LIMIT_EXCEEDED` - `{ limit }`, the limit that was hit.
* Every other code - an empty array.

Until you generate against a spec that describes its error bodies, `ApiError` falls back to `code: string` and `details: unknown`.

Branch on `code`, never on `error` - the message is meant for humans and its wording can change.

`isApiError` is auto-imported whenever at least one composable is enabled, whichever ones they are.

***

## Proxy

The module includes a built-in proxy to prevent CORS issues on client-side requests.

**1. Browser Request**

Client sends request to your server at `/api/frontic`

**2. Server Forwards**

Your Nuxt server forwards the request to the Fetch API

**3. Response Returns**

Response flows back through your server to the browser

> [!NOTE]
>
> Server-side requests (SSR) go directly to the Fetch API without using the
> proxy. They carry `fs-secret` too when you set [`fetchApiSecret`](#api), so
> every path is authenticated.

### What the proxy forwards

The proxy passes your request headers through to the Fetch API, minus three it strips first:

| Header | Why it is removed |
| - | - |
| `cookie` | Your storefront's session - auth and cart cookies the Fetch API never reads |
| `authorization` | Same reason |
| `fs-target-url` | Internal routing header, not part of the API |

Every other header is forwarded unchanged, including `fs-context`, `fs-version`, `fs-domain` and `fs-request-url`. `fs-secret` is injected server-side when you set [`fetchApiSecret`](#api). A request without `fs-target-url` gets `400 Bad Request`.

> [!WARNING]
>
> If you run your own proxy route instead of the module's, strip `cookie` and
> `authorization` yourself. Header forwarding keeps everything except a handful
> of hop-by-hop names, so a browser-side call otherwise hands your session
> cookies to the Fetch API.

### Timeouts

Calls that go through the proxy are bounded by [`proxyTimeout`](#api), 10 seconds by default. Server-rendered requests reach the Fetch API directly and are not covered by it:

```ts nuxt.config.ts theme={"theme":"css-variables"}
export default defineNuxtConfig({
  frontic: {
    proxyTimeout: 10000, // 0 disables the timeout
  },
})
```

***

## Sitemap

Frontic generates an XML sitemap for every domain of your project from the pages marked [Visible in Sitemap](https://docs.frontic.com/api-builder/pages.md#sitemaps). With `sitemap: true`, the module serves it from your Nuxt app:

```ts theme={"theme":"css-variables"}
export default defineNuxtConfig({
  frontic: {
    sitemap: true,
  },
});
```

Requests to `/sitemap.xml` - and `/sitemap1.xml`, `/sitemap2.xml`, … when the sitemap [is split](https://docs.frontic.com/reference/fetch-api/sitemap.md) - are answered with the sitemap for the matching domain, on every locale prefix. `https://demo-shop.com/sitemap.xml` and `https://demo-shop.com/uk/sitemap.xml` each serve their domain's sitemap, ready to submit to search engines or reference from your `robots.txt`:

```txt robots.txt theme={"theme":"css-variables"}
Sitemap: https://demo-shop.com/sitemap.xml
Sitemap: https://demo-shop.com/uk/sitemap.xml
```

The route resolves the domain from the incoming host and path prefix - the same way [URLs resolve](https://docs.frontic.com/api-builder/pages.md#how-urls-resolve) - injects your [fetchApiSecret](#api) server-side, and forwards the sitemap's `ETag` so crawlers revalidate instead of re-downloading. Responses are cached for ten minutes with stale-while-revalidate. When your config declares the app's domains (`sitemap: { domains: [...] }`, a `contextDomain` string, or `contextDomain: true` with i18n locales carrying `contextDomain` properties), the route serves only those domains and answers 404 for any other host. With no declared domains the route serves whatever well-formed host the request carries, and the module warns at build time - declare your domains on any deployment whose forwarded host header is client-controlled.

> [!NOTE]
>
> With [@nuxtjs/robots](https://nuxtseo.com/docs/robots) installed, the module
> adds the `Sitemap:` directive to your robots.txt automatically. With
> [@nuxtjs/sitemap](https://nuxtseo.com/docs/sitemap) installed, `sitemap: true`
> is ignored with a warning - both would claim `/sitemap.xml`. An integration
> that feeds Frontic pages into @nuxtjs/sitemap is planned.

To assemble your own sitemap instead - for example to merge Frontic pages into a larger set - leave the option off and fetch the XML through [`client.sitemap`](https://docs.frontic.com/reference/client-sdk#client-sitemap):

```ts theme={"theme":"css-variables"}
const client = useFronticClient();
const xml = await client.sitemap("demo-shop.com/uk");
```

***

## TypeScript

Composable types come from your [generated client](https://docs.frontic.com/reference/client-sdk.md). Run `frontic generate` and block, listing and tree names, listing parameters, filter and sort fields, and response types all resolve to your project's schema - with IDE autocomplete, and a type error for a name or field that doesn't exist. Until you generate, name arguments ask you to run the generator.

Response types such as `ProductCard` are declared globally by the generated client, so you use them without an import:

```ts theme={"theme":"css-variables"}
defineProps<{ product: ProductCard }>();
```

Helper types come from the module:

```ts theme={"theme":"css-variables"}
import type {
  BlockName,
  ResponseOf,
  ListingQuery,
  SortString,
  FronticClient,
} from "@frontic/nuxt/types";

type Card = ResponseOf<"ProductCard">;
type SearchSort = SortString<"ProductSearch">; // 'default' | 'name:asc' | …
```

`@frontic/nuxt/types` is a types-only entry - use `import type`. It also exports the [hook payloads](#hooks) and the [error body types](#error-handling), plus `Page`, `PageData`, `RouteMeta`, `Context` and `ContextOption`.

You import those by name, so they shadow a global of the same name - a project with a Data Type called `Page` keeps working. The generated types declare your project's own types as globals, so the framework shapes there sit under the `Frontic` namespace to stay out of their way; see [Types](https://docs.frontic.com/reference/client-sdk.md#types).

The module maps `@frontic/stack/*` to `.frontic/*` in your project root, where the CLI writes the runtime client. Use the alias to import the client itself:

```ts theme={"theme":"css-variables"}
import { createClient } from "@frontic/stack/generated-client";
```

> [!WARNING]
>
> The module reads its types from a registry that the CLI fills in since generator schema version 2. With an older `.frontic/` folder, no composable name type-checks, and the module warns at build time that your client is outdated. Run `frontic generate` with a current CLI to fix it.

***

## Caching

All composables use [Pinia Colada](https://pinia-colada.esm.dev/) for intelligent caching:

> [card] Stale-While-Revalidate
>
> Shows cached data immediately while fetching fresh data in the background

> [card] Automatic Deduplication
>
> Multiple components requesting the same data share a single request

> [card] SSR Hydration
>
> Data fetched on server transfers to client without duplicate requests

### What a Cache Entry Is Keyed By

`requestUrl` is deliberately **not** in the key: it is per-route request metadata, not content identity, and keying on it would split the cache into one entry per visited URL. If you are putting these responses behind a server cache of your own, pass `requestUrl: false` so the outgoing request stops varying per visitor too - and `contextKey: false` alongside it, for the same reason.

Alongside the composable's own arguments, every cache key carries the resolved [context domain](#contextdomain) and the active [context token](#the-context-token). Two locales resolving to different domains therefore never share an entry, and switching context moves every active query onto a new entry rather than reusing the old one - which is what fetches the new context's content.

This matters most when one process serves several domains - prerendering a multi-locale site, warming a cache, or rendering multiple tenants - where a domain-blind key would hand the second request the first one's payload.

### Configure Cache Duration

`staleTime` decides how long fetched data is trusted; `gcTime` decides how long an entry nobody is reading is kept before it is dropped.

```ts theme={"theme":"css-variables"}
const { block } = useFronticBlock("ProductFull", id, {
  staleTime: 1000 * 60 * 10, // trusted for 10 minutes
  gcTime: 1000 * 60 * 30, // kept for 30 minutes after the last component leaves
});
```

### Data on Screen While a Key Changes

A cache entry is keyed by everything the request depends on, so changing a filter, a page or a context is a different entry - with no data of its own yet. By default the composables hold the previous entry's data on screen while the new one loads, rather than blanking the view back to `pending`:

```ts theme={"theme":"css-variables"}
// Paging through results without the list disappearing between pages
const { result, status, isLoading } = useFronticSearch("ProductSearch");
// status stays 'success', isLoading goes true - render the old list, dimmed
```

The held value is a placeholder: it is never written to the cache, and the first load of all still reports `pending`. Turn it off with `keepPreviousData: false` where the stale content would mislead rather than reassure. `useFronticPage` already defaults to `false`, because there a changed key is a different page rather than the same one reloading.

### Seeding Data That Arrived With the Page

Frontic lets a page define the data it needs, so a category page's block can carry its listing in the same payload as the page. `initialData` hands that payload to the composable instead of fetching it again:

```ts theme={"theme":"css-variables"}
const { data } = useFronticPage();

const { listing, status } = useFronticListing(
  "CategoryProducts",
  { key: "shoes" },
  {
    initialData: () => data.value?.products,
  },
);
// status is 'success' on the first paint - no second round trip, no skeleton
```

The seed is written to the cache entry, so the query reports `success` and stays quiet while it is within `staleTime`. It survives SSR: the entry is serialised into the payload, and the client hydrates it without re-fetching.

Which raises the question of how old the payload is. The seed is treated as produced *now* unless you say otherwise, so a listing that travelled inside a cached page document is trusted as if it had just been fetched - stale prices and stock, marked fresh, for as long as `staleTime` lasts. Stamp it when the page document can outlive the request that renders it:

```ts theme={"theme":"css-variables"}
// Whatever your setup records: a timestamp stamped at prerender, the `Age` of
// the CDN response, a build time. The page response carries none of its own.
const { data } = useFronticPage();
const renderedAt = useState("page-rendered-at", () => Date.now());

useFronticListing("CategoryProducts", { key: "shoes" }, {
  initialData: () => data.value?.products,
  initialDataUpdatedAt: () => renderedAt.value,
});
// Older than staleTime → renders instantly, then refreshes in the background
```

Prerendering, ISR and a CDN in front of the page all produce this: one document, many visitors, one timestamp that is not `now` for any of them.

> [!WARNING]
>
> Seed only data that **is** the response. A seeded query is indistinguishable from a fetched one - same `status`, same `data` - so nothing downstream can tell that the numbers came from somewhere else. To merely avoid a skeleton on data that might be wrong, leave `keepPreviousData` to do it.

Only the query the composable starts on is seeded. The payload answers one question - one set of filters, one sort, one page - so the first filter, sort or page the user picks fetches for real rather than being handed data that answers a different one.

> [!WARNING]
>
> **The query it starts on is not always the plain one.** `initialFilters`, `initialSort` and any state [restored from the URL](#restoring-state-from-the-url) are applied *before* the seed is matched, so a filtered start is still a start. Seeding an unfiltered page payload there is accepted, marked fresh, and renders the wrong products - with nothing to distinguish it from a real response.
>
> Return `undefined` whenever the payload does not answer the question the search actually starts with:
>
> ```ts theme={"theme":"css-variables"}
> const route = useRoute();
> const { data } = useFronticPage();
>
> useFronticSearch("CategoryProducts", { key: "shoes" }, {
>   urlQuerySync: true,
>   // The block payload is the unfiltered listing. Once the URL narrows it,
>   // it is no longer the answer - fetch instead.
>   initialData: () =>
>     Object.keys(route.query).length ? undefined : data.value?.products,
> });
> ```
>
> In development the composable warns when it starts with filters, a sort, a search term or a page applied and `initialData` is set.

`useFronticSearch` takes the same single response in infinite mode and wraps it into the accumulated page set itself:

```ts theme={"theme":"css-variables"}
const { data } = useFronticPage();

const search = useFronticSearch(
  "CategoryProducts",
  { key: "shoes" },
  {
    infinite: true,
    initialData: () => data.value?.products, // one response, not { pages, pageParams }
  },
);
```

### Manual Cache Control

All composables return two methods for cache control:

| Method | Description |
| - | - |
| `refresh()` | Refresh data, using cache if still valid within `staleTime` |
| `refetch()` | Force a fresh fetch, completely bypassing the cache |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.