query
Cache data that isn't fetch (an ORM, an SDK) under a key, with the same tags.
query() is for data that isn't fetch: an ORM call, an SDK call, a file read. It caches the result of any server code under a key, and tags it the same way a tagged fetch is tagged, so revalidate() and the panel treat both alike. If you can tag a native fetch, do that instead: see Getting started.
Call query() in a Server Component, a route handler or a server action, and every read with the same key shares one cache entry.
fetch or query()?
Use a tagged native fetch whenever you can. Use query() only when the data doesn't come from Next's fetch: Next caches and tags fetch itself, and nothing else.
| Your data comes from | Use | Why |
|---|---|---|
fetch() to an HTTP API | fetch(url, { next: { tags, revalidate } }) | Next caches it and the panel reads it from Next's fetch cache |
axios, got, node:http | query() | On the server they don't go through fetch, so Next can't cache or tag them |
An ORM or a database driver (Prisma, Drizzle, pg) | query() | Not an HTTP call at all |
| An SDK (Stripe, an S3 client, a CMS client) | query() | Its internal requests don't accept next.tags |
| A file read, a computation | query() | Nothing for Next to cache |
Native fetch: no query()
import { tags } from '@angelitolm/next-query'
const res = await fetch('https://api.example.com/products', {
next: { tags: tags(['products']), revalidate: 60 },
})
const products = await res.json()axios: wrap it in query()
import axios from 'axios'
import { query } from '@angelitolm/next-query'
const products = await query(
['products'],
async () => (await axios.get('https://api.example.com/products')).data,
{ revalidate: 60 },
)Return .data, not the axios response: the result must be JSON-serializable, and the response object carries the request, headers and config.
Prisma (or any ORM): wrap it in query()
import { query } from '@angelitolm/next-query'
import { prisma } from '@/lib/prisma'
const product = await query(['products', id], () => prisma.product.findUnique({ where: { id } }))An SDK: wrap it in query()
import Stripe from 'stripe'
import { query } from '@angelitolm/next-query'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const prices = await query(['prices'], async () => (await stripe.prices.list({ active: true })).data, { revalidate: 300 })Mixing both under one tag
A fetch and a query() that share a tag are revalidated together. Here the list comes from an API and one product from the database, and revalidate('products') expires both:
fetch('https://api.example.com/products', { next: { tags: tags(['products']), revalidate: 60 } })
query(['products', id], () => prisma.product.findUnique({ where: { id } }))
// server action
revalidate('products')Don't wrap a fetch in query()
query(['products'], () => fetch(url).then((r) => r.json())) caches the same data twice, in unstable_cache and in Next's fetch cache, and the two can go out of sync. Tag the fetch instead.
Signature
function query<T>(key: QueryKey, fn: () => T | Promise<T>, config?: QueryConfig): Promise<T>
type QueryKey = (string | number)[]
type QueryConfig = { revalidate?: number | false }import { query } from '@angelitolm/next-query'
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await query(['products', id], () => db.product(id), { revalidate: 60 })
return <h1>{product.name}</h1>
}keynames the entry, and its tags aretags(key).revalidate(['products', id])andrevalidate('products')both reach it. The rules are in Tags.fnis any server code: an ORM call, an SDK call, a file read. It runs on a cache miss.config.revalidateis the number of seconds until the data is stale, orfalse(the default) to keep it until you revalidate it. Anything else, such as0, a negative number orNaN, throws aTypeError.
The result must be JSON-serializable
Next stores the value with unstable_cache, which stores JSON. On a cache hit you get the JSON round trip of what fn returned: a Date comes back as a string, and undefined values, functions and class instances do not survive.
const row = await query(['events', 1], async () => ({ at: new Date() }))
typeof row.at // 'string' on a cache hit, not a DateReturn plain data, and convert it after the read (new Date(row.at)).
Reuse a query with a plain function
There is no query object to share. Wrap the call in a function and import that:
import { query } from '@angelitolm/next-query'
export const getProduct = (id: string) => query(['products', id], () => db.product(id))The key lives in one place, so the page that reads it and the action that revalidates it cannot drift apart.
Errors
If fn throws, nothing is cached: the next read runs fn again. The error is rethrown, so it reaches your error.tsx as usual, and in development the panel shows it on the card with the error status.
notFound() and redirect() inside fn are control flow, not failures. They pass through untouched and are not recorded as errors.
import { notFound } from 'next/navigation'
import { query } from '@angelitolm/next-query'
const product = await query(['products', id], async () => {
const row = await db.product(id)
if (!row) notFound() // reaches Next's not-found page, not the panel's error list
return row
})Stale-while-revalidate
With revalidate: 60, the data is fresh for 60 seconds. The first read after that serves the old data and refetches in the background, so the request after it gets the new data. That is how unstable_cache works, and query() does not change it.
Want the new data right away?
Call revalidate() from a server action. It expires the entry immediately instead of waiting for the window.
How it works
query() wraps fn in unstable_cache with the key parts ['next-query', <JSON of the key>], and with the tags tags(key) (see Tags). Nothing else is stored, and 'use cache' is not used. Because the JSON of the key is part of the cache key, ['products', 1] and ['products', '1'] are different entries.