Documentation

Cache

Cache app data

Warm a TanStack Query entry in a VitNode plugin route loader, read it in the page without a second request, and invalidate it after a write.

The app cache in VitNode is TanStack Query. A plugin route's loader fills a query entry during SSR or navigation, the page reads that same entry without another request, and a write invalidates it so the next read is fresh.

This guide moves the site-notes list page onto the query cache. How the API call itself works is covered in Fetcher.

Before you begin

  • The /site-notes/notes page from Plugin page. Its route is already declared in src/routes.ts.
  • For the last step, the AdminCP form and its POST /api/site-notes/admin/notes route from AdminCP pages.

Define the query once

Create one queryOptions helper for the list. The loader, the page and the mutation all use it, so they always agree on the key.

plugins/site-notes/src/pages/notes-query.ts
import { queryOptions } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'

import { manifest } from '@/manifest.gen'

export const NOTES_QUERY_KEY = [manifest.id, 'notes'] as const

export const notesQueryOptions = () =>
  queryOptions({
    queryKey: NOTES_QUERY_KEY,
    queryFn: async () => {
      const response = await fetcher({
        plugin: manifest.id,
        method: 'get',
        module: 'notes',
        path: '/',
      })
      if (!response.ok) throw new Error('Could not load notes.')

      return await response.json()
    },
  })

If you followed AdminCP pages, this file already exists.

Start every key with your plugin ID. invalidateQueries matches keys by prefix, so a generic first segment such as 'notes' would let one plugin invalidate another plugin's data.

Warm the query in the route loader

Replace the loader in src/pages/notes-page.tsx with defineRoute from @vitnode/core/tanstack/plugin-routes. Its loader context includes queryClient; the framework-neutral definePluginRoute from @vitnode/core/routing only receives locale.

plugins/site-notes/src/pages/notes-page.tsx
import { useSuspenseQuery } from '@tanstack/react-query'
import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'
import { useTranslations } from 'use-intl'

import { notesQueryOptions } from './notes-query'

export const route = defineRoute({
  load: async ({ context }) => {
    await context.queryClient.query({ ...notesQueryOptions(), staleTime: 'static' }) 
  },
})

const NotesPage = () => {
  const t = useTranslations('site-notes.notes')
  const { data: notes } = useSuspenseQuery(notesQueryOptions())

  return (
    <main className="container mx-auto flex max-w-2xl flex-col gap-6 p-4">
      <h1 className="text-2xl font-semibold tracking-tight text-balance">{t('title')}</h1>
      <ul className="flex flex-col gap-3">
        {notes.map(note => (
          <li className="bg-card rounded-lg border p-4" key={note.id}>
            {note.title}
          </li>
        ))}
      </ul>
    </main>
  )
}

export default NotesPage

staleTime: 'static' makes the loader reuse an entry that is already cached instead of fetching again on every navigation. The page reads the entry the loader just filled, so it renders without suspending.

The router also runs this loader when a visitor rests on a link to the page, and again on the click. Because the loader reads through the cache, both runs share one request. See Preloading.

Invalidate the query after a write

VitNode's query client does not refetch when a component mounts or the tab regains focus. A cached list stays as it is until your code invalidates it, so invalidate it in every mutation that changes notes:

plugins/site-notes/src/pages/use-create-note.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
import { toast } from 'sonner'

import { manifest } from '@/manifest.gen'

import { NOTES_QUERY_KEY } from './notes-query'

export const useCreateNote = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (values: { title: string; body: string }) => {
      const response = await fetcher({
        plugin: manifest.id,
        method: 'post',
        module: 'admin/notes',
        path: '/',
        args: { body: values },
      })

      if (response.status !== 201) {
        throw new Error(`Creating a note answered ${response.status}.`)
      }
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: NOTES_QUERY_KEY }) 
      toast.success('Note created', {
        description: 'It now appears in the notes list.',
      })
    },
  })
}

The AdminCP form from AdminCP pages does the same in its onSubmit.

Invalidate the narrowest key that covers everything the write could change. For a list with paging or sorting, that is the list's root key: the changed row may have moved to a different page.

Put the user ID in per-user keys

For data that belongs to one signed-in user, such as drafts or settings, add the user ID to the query key. Otherwise, signing out and back in as someone else in the same tab can show the previous account's cached data.

Check the result

  1. Open http://localhost:3000/site-notes/notes with the browser's Network tab open. The list renders without a browser request for notes, because the loader filled the cache during SSR.
  2. Navigate to another page and back with a link. No new request for notes is sent.
  3. Create a note in the AdminCP, then open the notes page in the same tab. The new note is there, because the write invalidated the cached list.

To cache the database work behind the notes endpoint as well, continue with Cache API data.