diff --git a/docs/1.getting-started/7.state-management.md b/docs/1.getting-started/7.state-management.md
index 4687e0eb05..9cec1e9c5c 100644
--- a/docs/1.getting-started/7.state-management.md
+++ b/docs/1.getting-started/7.state-management.md
@@ -57,9 +57,68 @@ const counter = useState('counter', () => Math.round(Math.random() * 1000))
To globally invalidate cached state, see [`clearNuxtState`](/docs/api/utils/clear-nuxt-state) util.
::
-### Advanced Usage
+### Initializing State
-In this example, we use a composable that detects the user's default locale from the HTTP request headers and keeps it in a `locale` state.
+Most of the time, you will want to initialize your state with data that resolves asynchronously. You can use the [`app.vue`](/docs/guide/directory-structure/app) component with the [`callOnce`](/docs/api/utils/call-once) util to do so.
+
+```vue [app.vue]
+
+```
+
+::callout
+This is similar to the [`nuxtServerInit` action](https://v2.nuxt.com/docs/directory-structure/store/#the-nuxtserverinit-action) in Nuxt 2, which allows filling the initial state of your store server-side before rendering the page.
+::
+
+:read-more{to="/docs/api/utils/call-once"}
+
+### Usage with Pinia
+
+In this example, we leverage the [Pinia module](/modules/pinia) to create a global store and use it across the app.
+
+::callout
+Make sure to install the Pinia module with `npx nuxi@latest module add pinia` or follow the [module's installation steps](https://pinia.vuejs.org/ssr/nuxt.html#Installation).
+::
+
+::code-group
+```ts [stores/website.ts]
+export const useWebsiteStore = defineStore('websiteStore', {
+ state: () => ({
+ name: '',
+ description: ''
+ }),
+ actions: {
+ async fetch() {
+ const infos = await $fetch('https://api.nuxt.com/modules/pinia')
+
+ this.name = infos.name
+ this.description = infos.description
+ }
+ }
+})
+```
+```vue [app.vue]
+
+
+
+
+
{{ website.name }}
+
{{ website.description }}
+
+
+```
+::
+
+## Advanced Usage
::code-group
```ts [composables/locale.ts]
diff --git a/docs/3.api/2.composables/use-fetch.md b/docs/3.api/2.composables/use-fetch.md
index 2fb3593978..5222b0fcb6 100644
--- a/docs/3.api/2.composables/use-fetch.md
+++ b/docs/3.api/2.composables/use-fetch.md
@@ -17,11 +17,9 @@ It automatically generates a key based on URL and fetch options, provides type h
## Usage
-```vue [pages/index.vue]
+```vue [pages/modules.vue]
@@ -35,12 +33,12 @@ Using the `query` option, you can add search parameters to your query. This opti
```ts
const param1 = ref('value1')
-const { data, pending, error, refresh } = await useFetch('https://api.nuxtjs.dev/mountains', {
+const { data, pending, error, refresh } = await useFetch('/api/modules', {
query: { param1, param2: 'value2' }
})
```
-The above example results in `https://api.nuxtjs.dev/mountains?param1=value1¶m2=value2`.
+The above example results in `https://api.nuxt.com/modules?param1=value1¶m2=value2`.
You can also use [interceptors](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors):
diff --git a/docs/3.api/2.composables/use-state.md b/docs/3.api/2.composables/use-state.md
index 6d5a8d4294..d4be8390b5 100644
--- a/docs/3.api/2.composables/use-state.md
+++ b/docs/3.api/2.composables/use-state.md
@@ -13,7 +13,6 @@ links:
```ts
// Create a reactive state and set default value
const count = useState('counter', () => Math.round(Math.random() * 100))
-
```
:read-more{to="/docs/getting-started/state-management"}
diff --git a/docs/3.api/3.utils/call-once.md b/docs/3.api/3.utils/call-once.md
new file mode 100644
index 0000000000..b148c39975
--- /dev/null
+++ b/docs/3.api/3.utils/call-once.md
@@ -0,0 +1,54 @@
+---
+title: "callOnce"
+description: "Run a given function or block of code once during SSR or CSR."
+links:
+ - label: Source
+ icon: i-simple-icons-github
+ to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/once.ts
+ size: xs
+---
+
+## Purpose
+
+The `callOnce` function is designed to execute a given function or block of code only once during:
+- server-side rendering but not hydration
+- client-side navigation
+
+This is useful for code that should be executed only once, such as logging an event or setting up a global state.
+
+## Usage
+
+```vue [app.vue]
+
+```
+
+::callout{to="/docs/getting-started/state-management#usage-with-pinia"}
+`callOnce` is useful in combination with the [Pinia module](/modules/pinia) to call store actions.
+::
+
+:read-more{to="/docs/getting-started/state-management"}
+
+::callout{color="info" icon="i-ph-warning-duotone"}
+Note that `callOnce` doesn't return anything. You should use [`useAsyncData`](/docs/api/composables/use-async-data) or [`useFetch`](/docs/api/composables/use-fetch) if you want to do data fetching during SSR.
+::
+
+::callout
+`callOnce` is a composable meant to be called directly in a setup function, plugin, or route middleware, because it needs to add data to the Nuxt payload to avoid re-calling the function on the client when the page hydrates.
+::
+
+## Type
+
+```ts
+callOnce(fn?: () => any | Promise): Promise
+callOnce(key: string, fn?: () => any | Promise): Promise
+```
+
+- `key`: A unique key ensuring that the code is run once. If you do not provide a key, then a key that is unique to the file and line number of the instance of `callOnce` will be generated for you.
+- `fn`: The function to run once. This function can also return a `Promise` and a value.
diff --git a/docs/3.api/3.utils/clear-error.md b/docs/3.api/3.utils/clear-error.md
index f2f8897a91..915bd7852e 100644
--- a/docs/3.api/3.utils/clear-error.md
+++ b/docs/3.api/3.utils/clear-error.md
@@ -8,8 +8,6 @@ links:
size: xs
---
-# `clearError`
-
Within your pages, components, and plugins, you can use `clearError` to clear all errors and redirect the user.
**Parameters:**
diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts
index a815dbd05e..5855089873 100644
--- a/packages/nuxt/src/app/composables/index.ts
+++ b/packages/nuxt/src/app/composables/index.ts
@@ -16,6 +16,7 @@ export { defineNuxtComponent } from './component'
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData'
export type { AsyncDataOptions, AsyncData } from './asyncData'
export { useHydration } from './hydrate'
+export { callOnce } from './once'
export { useState, clearNuxtState } from './state'
export { clearError, createError, isNuxtError, showError, useError } from './error'
export type { NuxtError } from './error'
diff --git a/packages/nuxt/src/app/composables/once.ts b/packages/nuxt/src/app/composables/once.ts
new file mode 100644
index 0000000000..52ece887c4
--- /dev/null
+++ b/packages/nuxt/src/app/composables/once.ts
@@ -0,0 +1,31 @@
+import { useNuxtApp } from '../nuxt'
+
+/**
+ * An SSR-friendly utility to call a method once
+ * @param key a unique key ensuring the function can be properly de-duplicated across requests
+ * @param fn a function to call
+ * @see https://nuxt.com/docs/api/utils/call-once
+ */
+export function callOnce (key?: string, fn?: (() => any | Promise)): Promise
+export function callOnce (fn?: (() => any | Promise)): Promise
+export async function callOnce (...args: any): Promise {
+ const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
+ if (typeof args[0] !== 'string') { args.unshift(autoKey) }
+ const [_key, fn] = args as [string, (() => any | Promise)]
+ if (!_key || typeof _key !== 'string') {
+ throw new TypeError('[nuxt] [callOnce] key must be a string: ' + _key)
+ }
+ if (fn !== undefined && typeof fn !== 'function') {
+ throw new Error('[nuxt] [callOnce] fn must be a function: ' + fn)
+ }
+ const nuxtApp = useNuxtApp()
+ // If key already ran
+ if (nuxtApp.payload.once.has(_key)) {
+ return
+ }
+ nuxtApp._once = nuxtApp._once || {}
+ nuxtApp._once[_key] = nuxtApp._once[_key] || fn()
+ await nuxtApp._once[_key]
+ nuxtApp.payload.once.add(_key)
+ delete nuxtApp._once[_key]
+}
diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts
index 8ce0d22f06..218e252c50 100644
--- a/packages/nuxt/src/app/nuxt.ts
+++ b/packages/nuxt/src/app/nuxt.ts
@@ -77,6 +77,7 @@ export interface NuxtPayload {
prerenderedAt?: number
data: Record
state: Record
+ once: Set
config?: Pick
error?: Error | {
url: string
@@ -126,6 +127,11 @@ interface _NuxtApp {
named: Record
}
+ /** @internal */
+ _once: {
+ [key: string]: Promise
+ }
+
/** @internal */
_observer?: { observe: (element: Element, callback: () => void) => () => void }
/** @internal */
@@ -232,6 +238,7 @@ export function createNuxtApp (options: CreateOptions) {
payload: reactive({
data: {},
state: {},
+ once: new Set(),
_errors: {},
...(import.meta.client ? window.__NUXT__ ?? {} : { serverRendered: true })
}),
diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts
index 72f33b42b3..9085acddfa 100644
--- a/packages/nuxt/src/core/runtime/nitro/renderer.ts
+++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts
@@ -149,7 +149,8 @@ const getSPARenderer = lazyCachedFunction(async () => {
_errors: {},
serverRendered: false,
data: {},
- state: {}
+ state: {},
+ once: new Set()
}
ssrContext.config = {
public: config.public,
diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts
index 3e4d8d1da4..4404893b4a 100644
--- a/packages/nuxt/src/imports/presets.ts
+++ b/packages/nuxt/src/imports/presets.ts
@@ -41,6 +41,10 @@ const granularAppPresets: InlinePreset[] = [
imports: ['useHydration'],
from: '#app/composables/hydrate'
},
+ {
+ imports: ['callOnce'],
+ from: '#app/composables/once'
+ },
{
imports: ['useState', 'clearNuxtState'],
from: '#app/composables/state'
diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts
index 9025441b1c..ec93de6b1f 100644
--- a/packages/schema/src/config/build.ts
+++ b/packages/schema/src/config/build.ts
@@ -136,6 +136,7 @@ export default defineUntypedSchema({
*/
keyedComposables: {
$resolve: val => [
+ { name: 'callOnce', argumentLength: 2 },
{ name: 'defineNuxtComponent', argumentLength: 2 },
{ name: 'useState', argumentLength: 2 },
{ name: 'useFetch', argumentLength: 3 },
diff --git a/test/basic.test.ts b/test/basic.test.ts
index 1b2172c7e3..80c723cf29 100644
--- a/test/basic.test.ts
+++ b/test/basic.test.ts
@@ -863,6 +863,18 @@ describe('navigate external', () => {
})
})
+describe('composables', () => {
+ it('should run code once', async () => {
+ const html = await $fetch('/once')
+
+ expect(html).toContain('once.vue')
+ expect(html).toContain('once: 2')
+
+ const { page } = await renderPage('/once')
+ expect(await page.getByText('once:').textContent()).toContain('once: 2')
+ })
+})
+
describe('middlewares', () => {
it('should redirect to index with global middleware', async () => {
const html = await $fetch('/redirect/')
diff --git a/test/fixtures/basic/pages/once.vue b/test/fixtures/basic/pages/once.vue
new file mode 100644
index 0000000000..963014ee7f
--- /dev/null
+++ b/test/fixtures/basic/pages/once.vue
@@ -0,0 +1,14 @@
+
+
+
+