---
title: "Persist Pinia state with a plugin · vuejs.school"
description: "Write a Pinia plugin that saves a store to localStorage, restores it on load, stays off the server and versions its data. Then compare it with the popular package."
canonical_url: "https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin"
meta:
  "og:description": "Write a Pinia plugin that saves a store to localStorage, restores it on load, stays off the server and versions its data. Then compare it with the popular package."
  "og:image": "https://vuejs.school/_og/d/a_~UGVyc2lzdCBQaW5pYSBzdGF0ZSB3aXRoIGEgcGx1Z2luIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Persist%2BPinia%2Bstate%2Bwith%2Ba%2Bplugin%2Cdescription_~V3JpdGUgYSBQaW5pYSBwbHVnaW4gdGhhdCBzYXZlcyBhIHN0b3JlIHRvIGxvY2FsU3RvcmFnZSwgcmVzdG9yZXMgaXQgb24gbG9hZCwgc3RheXMgb2ZmIHRoZSBzZXJ2ZXIgYW5kIHZlcnNpb25zIGl0cyBkYXRhLiBUaGVuIGNvbXBhcmUgaXQgd2l0aCB0aGUgcG9wdWxhciBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJzaXN0LXBpbmlhLXN0YXRlLXdpdGgtYS1wbHVnaW4i%2Cs_x-P5Ubao-cLl-oJO.png"
  "og:image:alt": "Persist Pinia state with a plugin · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Persist Pinia state with a plugin · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~UGVyc2lzdCBQaW5pYSBzdGF0ZSB3aXRoIGEgcGx1Z2luIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Persist%2BPinia%2Bstate%2Bwith%2Ba%2Bplugin%2Cdescription_~V3JpdGUgYSBQaW5pYSBwbHVnaW4gdGhhdCBzYXZlcyBhIHN0b3JlIHRvIGxvY2FsU3RvcmFnZSwgcmVzdG9yZXMgaXQgb24gbG9hZCwgc3RheXMgb2ZmIHRoZSBzZXJ2ZXIgYW5kIHZlcnNpb25zIGl0cyBkYXRhLiBUaGVuIGNvbXBhcmUgaXQgd2l0aCB0aGUgcG9wdWxhciBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJzaXN0LXBpbmlhLXN0YXRlLXdpdGgtYS1wbHVnaW4i%2Cs_x-P5Ubao-cLl-oJO.png"
  "twitter:image:alt": "Persist Pinia state with a plugin · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What a Pinia plugin is

A plugin is a function that Pinia calls once per store, at the moment the store is first used. It receives a context object with four fields, as the [plugins guide](https://pinia.vuejs.org/core-concepts/plugins.html) describes:

- `store`: the store being created.
- `options`: the options you passed to `defineStore()`.
- `app`: the Vue application.
- `pinia`: the instance from `createPinia()`.

Whatever the function returns is merged into the store, which is how plugins add properties. Ours adds none; it only needs `store` and `options`.

You register a plugin with `pinia.use()`, and the order matters: plugins only apply to stores created after the plugin was added and after Pinia was passed to the app. So the registration goes in `main.ts`, before `mount`:

*src/main.ts*

```
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { persistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(persistPlugin)

createApp(App).use(pinia).mount('#app')
```

## Step 1: save on every change

Every store has `$subscribe()`, which runs a callback after the state changes. It fires once per `$patch()` call rather than once per property, which is what makes it better than `watch()` for this job. The callback receives a `mutation` object and the new `state`; the plugin only needs the state.

*src/plugins/persist.ts*

```
import type { PiniaPluginContext } from 'pinia'

export function persistPlugin({ store }: PiniaPluginContext) {
  const key = `pinia:${store.$id}`

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  }, { detached: true })
}
```

The key includes `store.$id`, the string you gave `defineStore()`, so a `cart` store and a `user` store never overwrite each other.

By default, a subscription made in a component's `setup()` is removed when that component unmounts, and `{ detached: true }` prevents that. Pinia runs plugins in the store's own scope, not the component's, so a plugin's subscription lasts as long as the store anyway; the option only makes that explicit.

## Step 2: restore when the store is created

Step 1 alone loses data. After a reload the store starts empty, and the first change saves that fresh state over the stored one. The plugin must read the saved state back when the store is created. Do it before subscribing, or the restore triggers a write of the data you just read. `$patch()` with an object merges it into the state, which is exactly a restore:

*src/plugins/persist.ts*

```
import type { PiniaPluginContext, StateTree } from 'pinia'

export function persistPlugin({ store }: PiniaPluginContext) {
  const key = `pinia:${store.$id}`

  const saved = localStorage.getItem(key)
  if (saved) {
    try {
      store.$patch(JSON.parse(saved) as StateTree)
    } catch {
      localStorage.removeItem(key)
    }
  }

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  }, { detached: true })
}
```

The `try` matters because storage is outside your control: the user can edit it in DevTools, an extension or another script on the same origin can write to it, and `JSON.parse` throws on anything that is not JSON. Without the `try`, one bad string stops the store from being created, and the bug exists only in that user's browser. Throw the bad value away and start fresh.

## Step 3: let each store opt in

Persisting every store is wrong, for reasons the last section covers, so the plugin should do nothing unless a store asks. The guide shows how plugins read custom options: anything extra you pass to `defineStore()` arrives in `context.options`. For an options store it sits beside `state` and `actions`. For a setup store it is the third argument.

*src/stores/cart.ts*

```
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items = ref<{ id: string, qty: number }[]>([])
  const count = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))

  function add(id: string) {
    const existing = items.value.find(item => item.id === id)
    if (existing) existing.qty++
    else items.value.push({ id, qty: 1 })
  }

  return { items, count, add }
}, { persist: true })
```

TypeScript does not know about `persist` yet and rejects `{ persist: true }` as an unknown option. Declare it with a module augmentation, a `declare module 'pinia'` block that adds the field to Pinia's `DefineStoreOptionsBase` interface, as the guide's TypeScript section does. Put it in the plugin file, next to the code that reads it:

*src/plugins/persist.ts*

```
declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: boolean | { version: number }
  }
}

export function persistPlugin({ store, options }: PiniaPluginContext) {
  if (!options.persist) return
  // ...the save and restore code from above
}
```

Now a store without the option is untouched, and a typo like `persits` is a compile error instead of a silent no-op.

## Step 4: keep it off the server

If the app is server-rendered, with Nuxt or with Vite SSR, the plugin also runs on the server, where `localStorage` does not exist and the call throws. A one-line guard at the top of the plugin fixes it:

*src/plugins/persist.ts*

```
export function persistPlugin({ store, options }: PiniaPluginContext) {
  if (!options.persist || typeof window === 'undefined') return
  // ...
}
```

In Nuxt there is one more step: register the plugin in a Nuxt plugin, since Nuxt creates the Pinia instance for you. Following the guide's Nuxt example, that is `defineNuxtPlugin(({ $pinia }) => { $pinia.use(persistPlugin) })`, in a file under `app/plugins/` in a Nuxt 4 project.

The guard stops the crash, but a server-rendered app has a second problem. The server renders the store without the saved data, and when the plugin restores it on the client, the first client render can differ from the server HTML. Vue reports that as a hydration mismatch. To avoid it, move the restore out of the plugin and run it after hydration, for example from `onMounted()` in the root component (`App.vue`, or `app.vue` in Nuxt). For Nuxt, the package compared below has a module built for server rendering.

## Step 5: version the saved data

The shape of your state will change. You rename `items` to `lines`, or `qty` becomes an object. Every visitor who has old data in storage then patches an old shape into a new store, and the result is a cart that renders `undefined`. Store a version number next to the data and refuse anything saved under another version. This is the complete plugin file:

*src/plugins/persist.ts*

```
import type { PiniaPluginContext, StateTree } from 'pinia'

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: boolean | { version: number }
  }
}

export function persistPlugin({ store, options }: PiniaPluginContext) {
  if (!options.persist || typeof window === 'undefined') return

  const version = typeof options.persist === 'object' ? options.persist.version : 1
  const key = `pinia:${store.$id}`

  const saved = localStorage.getItem(key)
  if (saved) {
    try {
      const parsed = JSON.parse(saved) as { v: number, data: StateTree }
      if (parsed.v === version) store.$patch(parsed.data)
      else localStorage.removeItem(key)
    } catch {
      localStorage.removeItem(key)
    }
  }

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify({ v: version, data: state }))
  }, { detached: true })
}
```

The saved value is now `{ v, data }`, and a store with `persist: true` counts as version 1. When the cart's shape changes, bump the store to `{ persist: { version: 2 } }` and old data is dropped on the next visit. If dropping is too harsh for a store, add a `migrate(old)` function to the option and call it instead of `removeItem`.

## When to use pinia-plugin-persistedstate instead

`pinia-plugin-persistedstate` does the same job, ready-made, and 4.5 is the current release. Install it and register it the way you registered `persistPlugin`:

```
npm install pinia-plugin-persistedstate
```

*src/main.ts*

```
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

pinia.use(piniaPluginPersistedstate)
```

A store opts in with the same `persist: true`, and the package's [configuration](https://prazdevs.github.io/pinia-plugin-persistedstate/guide/config.html) adds the options you would end up writing yourself:

- `key`: the storage key, `store.$id` by default.
- `storage`: `localStorage` by default, and `sessionStorage` is a one-word swap. A custom storage must be synchronous.
- `pick` and `omit`: persist only part of the state, with dot paths.
- `serializer`: for anything JSON cannot hold.
- `beforeHydrate` and `afterHydrate`: hooks that run before and after the restore.

It also ships a Nuxt module, which stores state in cookies by default because the server can read them too. A cookie holds only about 4 KB, so keep those stores small.

What it does not have is a version number, so the migration problem from step 5 is still yours. The package is the right default for a team. The hand-written plugin is right when you want versioning, an asynchronous storage such as IndexedDB, or simply no dependency.

If what you want to keep is one value rather than a store, neither is the tool. VueUse's `useStorage()` binds a single `ref` to storage in one line, and an earlier post on this blog covers it.

## What not to persist

Persisting is easy, which is why it is overused. Four things should stay in memory:

- **Tokens and anything secret.** `localStorage` is readable by any script on the page, so a cross-site scripting bug turns into a stolen session. Keep tokens in an `httpOnly` cookie set by the server.
- **Server data.** A product list copied into storage is stale the moment the server changes. Refetch it; cache it with the fetching layer if you must.
- **Large lists.** Browsers give an origin a few megabytes of `localStorage`, writes are synchronous, and the plugin serialises the whole state on every change. A thousand-row table persisted on each keystroke will make the page stutter.
- **Values JSON cannot represent.** A `Date` becomes a string and a `Map` becomes `{}`. Store the ISO string or the array, or give the plugin a serializer.

What is left is small, user-owned and cheap to rebuild: the cart, a draft, the chosen theme, the last open tab.

**Check it stuck**3 questions from the Persisting state and plugins quiz.

[**Practice now**](https://vuejs.school/learn/pinia/persisting-state-and-plugins?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Upgrading a Pinia 3 project to Pinia 4, with the Nuxt and testing packages**](https://vuejs.school/community/blog/upgrading-a-pinia-3-project-to-pinia-4) [Release notes · 7 min read**Pinia 4: what changed and what to do**](https://vuejs.school/community/blog/pinia-4-what-changed-and-what-to-do)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-10-07","description":"Write a Pinia plugin that saves a store to localStorage, restores it on load, stays off the server and versions its data. Then compare it with the popular package.","name":"Persist Pinia state with a plugin","url":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin","breadcrumb":{"@id":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-10-07","description":"Write a Pinia plugin that saves a store to localStorage, restores it on load, stays off the server and versions its data. Then compare it with the popular package.","headline":"Persist Pinia state with a plugin","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~UGVyc2lzdCBQaW5pYSBzdGF0ZSB3aXRoIGEgcGx1Z2luIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Persist%2BPinia%2Bstate%2Bwith%2Ba%2Bplugin%2Cdescription_~V3JpdGUgYSBQaW5pYSBwbHVnaW4gdGhhdCBzYXZlcyBhIHN0b3JlIHRvIGxvY2FsU3RvcmFnZSwgcmVzdG9yZXMgaXQgb24gbG9hZCwgc3RheXMgb2ZmIHRoZSBzZXJ2ZXIgYW5kIHZlcnNpb25zIGl0cyBkYXRhLiBUaGVuIGNvbXBhcmUgaXQgd2l0aCB0aGUgcG9wdWxhciBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJzaXN0LXBpbmlhLXN0YXRlLXdpdGgtYS1wbHVnaW4i%2Cs_x-P5Ubao-cLl-oJO.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/persist-pinia-state-with-a-plugin#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Persist Pinia state with a plugin","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~UGVyc2lzdCBQaW5pYSBzdGF0ZSB3aXRoIGEgcGx1Z2luIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Persist%2BPinia%2Bstate%2Bwith%2Ba%2Bplugin%2Cdescription_~V3JpdGUgYSBQaW5pYSBwbHVnaW4gdGhhdCBzYXZlcyBhIHN0b3JlIHRvIGxvY2FsU3RvcmFnZSwgcmVzdG9yZXMgaXQgb24gbG9hZCwgc3RheXMgb2ZmIHRoZSBzZXJ2ZXIgYW5kIHZlcnNpb25zIGl0cyBkYXRhLiBUaGVuIGNvbXBhcmUgaXQgd2l0aCB0aGUgcG9wdWxhciBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJzaXN0LXBpbmlhLXN0YXRlLXdpdGgtYS1wbHVnaW4i%2Cs_x-P5Ubao-cLl-oJO.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~UGVyc2lzdCBQaW5pYSBzdGF0ZSB3aXRoIGEgcGx1Z2luIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Persist%2BPinia%2Bstate%2Bwith%2Ba%2Bplugin%2Cdescription_~V3JpdGUgYSBQaW5pYSBwbHVnaW4gdGhhdCBzYXZlcyBhIHN0b3JlIHRvIGxvY2FsU3RvcmFnZSwgcmVzdG9yZXMgaXQgb24gbG9hZCwgc3RheXMgb2ZmIHRoZSBzZXJ2ZXIgYW5kIHZlcnNpb25zIGl0cyBkYXRhLiBUaGVuIGNvbXBhcmUgaXQgd2l0aCB0aGUgcG9wdWxhciBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJzaXN0LXBpbmlhLXN0YXRlLXdpdGgtYS1wbHVnaW4i%2Cs_x-P5Ubao-cLl-oJO.png"}]}
```