Hi everyone! I’m debugging some stubborn cache/stale-data behaviour in an App Router project and would love a second pair of eyes. Environment Next.js 15.4.0-canary.109 (App Router, running with next dev --turbopack) Node.js 22.17.0 Experimental partial prerendering enabled with ppr: 'incremental' Data comes from a Directus REST API; we’re not using the Next.js app/api routes for this entity. What we expect After creating/updating/deleting a customer we call revalidateTag('customersSICC') and revalidatePath('/dashboard/customersSICC'), then redirect back to the list. The list view should show the fresh data immediately. What actually happens Go to /dashboard/customersSICC (this page uses export const dynamic = 'force-dynamic' and renders a wrapped in ). Click “Crear Cliente”, fill out the form, submit. The server action runs, writes to Directus, calls revalidateTag/revalidatePath, and redirects back. The list view shows the old data until we manually refresh the browser. The same thing happens after edit/delete. Client-side navigation via our RefreshLink (which calls router.refresh() inside startTransition) doesn’t help.
// app/lib/actions.tsexport async function createCustomerSICC(formData: FormData) { const name = String(formData.get('name') || ''); const body = { status: formData.get('status'), name, CUIT: formData.get('CUIT'), contacto: formData.get('contacto'), mail: formData.get('mail'), tel: formData.get('tel'), mailNotif: formData.get('mailNotif'), urlSlug: slugify(name), };
const response = await fetch(`${DIRECTUS_URL}/items/Clientes`, { next: { revalidate: 0 }, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), });
await response.json(); // Directus returns the created record
revalidateTag('customersSICC'); revalidatePath('/dashboard/customersSICC'); revalidatePath('/', 'layout'); redirect('/dashboard/customersSICC');}
export async function deleteCustomerSICC(id: string) { await fetch(`${DIRECTUS_URL}/items/Clientes/${id}`, { next: { revalidate: 0 }, method: 'DELETE', }); revalidateTag('customersSICC'); revalidatePath('/dashboard/customersSICC'); redirect('/dashboard/customersSICC');}
// app/lib/data.tsconst ITEMS_PER_PAGE = 6;
export async function getCustomersSICC(query = "", currentPage = 1) { const url = `${DIRECTUS_URL}/items/Clientes?page=${currentPage}&limit=${ITEMS_PER_PAGE}&sort=name${ query ? `&filter[name][_contains]=${encodeURIComponent(query)}` : '' }`;
try { const res = await fetch(url, { next: { tags: ['customersSICC'] }, }); if (!res.ok) throw new Error('Failed to fetch customers'); const data = await res.json(); return data.data; } catch (err) { console.error('Fetch customers failed', err); return []; }}
The list page simply calls getCustomersSICC(query, currentPage) inside the server component and renders the result. Pagination counts come from a similar Directus request using the same tag (that request is cache: 'no-store').
Things we’ve tried
-
Ensured the page is dynamic (
export const dynamic = 'force-dynamic'). -
Added
revalidatePath('/', 'layout')for good measure. -
Confirmed
redirect('/dashboard/customersSICC')happens after the revalidation calls. -
Wrapped our navigation buttons with a custom
RefreshLinkthat doesrouter.push(...)+router.refresh()insidestartTransition. -
Verified that Directus receives the mutation and returns the new record ID.
Still, the data in the table is stale until a hard refresh. Are we missing something about how revalidateTag interacts with fetches that omit cache: 'no-store' in Next 15? Could partial prerendering be keeping the old snapshot around even with force-dynamic? Any guidance on diagnosing this would be hugely appreciated!