Optimistic UI with SWR mutate for CMS Edits
Within SWR Stale-While-Revalidate Patterns, this guide covers the write path: updating the SWR cache before a CMS management API call finishes, so an edit, a comment or a reaction appears instantly, and rolling it back cleanly when the CMS rejects it.
Most headless sites only read from the CMS. The exceptions are exactly the features readers notice: comments stored as CMS entries, “was this helpful” votes, bookmarks, and in-context editing tools that let editors fix a typo on the live page. Each of these writes goes through a server route to the management API (Contentful’s CMA, Strapi’s REST API with a write token, Directus’s items endpoint), which takes anywhere from 200 ms to over a second. Without optimistic updates, the UI freezes or shows a spinner for that long after every click.
The Problem
A developer documentation site stores reader comments as entries in Strapi, moderated before they appear publicly but visible immediately to their author. The first version posts the comment, waits for the server, then calls mutate() to refetch the thread. On a good connection that takes 700 ms: 400 for the write and 300 for the refetch. On mobile it takes two seconds, during which readers click “Post” again and create duplicates. When Strapi rejects a comment for exceeding a length validation, the text simply disappears, because the form was cleared before the request started.
Optimistic updates fix the latency, but naive implementations introduce new bugs: the optimistic comment briefly appears twice after the refetch, a failed write leaves a phantom comment on screen, or two quick edits race and the older response overwrites the newer state. SWR’s mutate options handle all three when configured deliberately.
How Optimistic Mutation Works in SWR
mutate(key, data, options) accepts a promise or an async function as data. With optimisticData, SWR writes that value into the cache immediately and re-renders every component using the key. When the promise resolves, populateCache decides what to store: true stores the resolved value, and a function lets you merge the server’s response into the current cache state. With rollbackOnError: true, a rejected promise restores the data that was in the cache before the optimistic write. Finally, revalidate controls whether SWR refetches the key afterwards to confirm the state with the server.
The combination that works best for CMS writes is: optimistic data for instant feedback, populateCache with a merge function so the server-assigned id and timestamps replace the temporary values, rollbackOnError for failures, and revalidate: false when the server response already contains everything the view needs. That avoids the extra refetch that causes the duplicate flash.
Implementation
The hook below posts a comment. It gives the optimistic item a temporary id and a pending flag, merges the server’s entry in place of it on success, and restores the previous thread on failure. The server route holds the CMS write token; the browser never sees it.
// hooks/use-comments.ts
import useSWR, { useSWRConfig } from "swr";
export interface Comment {
id: string;
body: string;
author: string;
createdAt: string;
pending?: boolean;
}
interface Thread {
articleId: string;
comments: Comment[];
}
const threadKey = (articleId: string): string => `/api/cms/comments?article=${articleId}`;
async function postComment(articleId: string, body: string): Promise<Comment> {
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ articleId, body }),
});
if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(detail.message ?? `Comment rejected (${res.status})`);
}
return (await res.json()) as Comment;
}
export function useComments(articleId: string, currentUser: string) {
const key = threadKey(articleId);
const { data, error } = useSWR<Thread>(key);
const { mutate } = useSWRConfig();
async function addComment(body: string): Promise<void> {
const tempId = `temp-${crypto.randomUUID()}`;
const optimistic: Comment = { id: tempId, body, author: currentUser, createdAt: new Date().toISOString(), pending: true };
await mutate<Thread, Comment>(key, postComment(articleId, body), {
optimisticData: (current) => ({
articleId,
comments: [...(current?.comments ?? []), optimistic],
}),
// Replace the temporary item with the saved one; keep everything else.
populateCache: (saved, current) => ({
articleId,
comments: (current?.comments ?? []).map((c) => (c.id === tempId ? saved : c)),
}),
rollbackOnError: true,
revalidate: false,
});
}
return { thread: data, error, addComment };
}
The server route validates input before touching the CMS, so most failures are fast and deterministic:
// app/api/comments/route.ts
import { z } from "zod";
const Input = z.object({ articleId: z.string().min(1), body: z.string().min(2).max(2000) });
export async function POST(req: Request): Promise<Response> {
const parsed = Input.safeParse(await req.json());
if (!parsed.success) return Response.json({ message: "Comments must be 2 to 2,000 characters." }, { status: 422 });
const res = await fetch(`${process.env.STRAPI_URL}/api/comments`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.STRAPI_WRITE_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ data: { article: parsed.data.articleId, body: parsed.data.body, status: "pending_review" } }),
});
if (!res.ok) return Response.json({ message: "The comment could not be saved." }, { status: 502 });
const { data } = (await res.json()) as { data: { documentId: string; body: string; createdAt: string } };
return Response.json({ id: data.documentId, body: data.body, author: "you", createdAt: data.createdAt }, { status: 201 });
}
In the component, render pending comments with reduced opacity and an “Posting…” label, and show the error message from the rejected promise next to the form. Keep the form text until the promise settles, so a rollback does not throw away what the reader typed.
Configuration Reference
mutate option |
Value | Purpose |
|---|---|---|
optimisticData |
function of current data | Applied immediately; a function avoids overwriting concurrent changes. |
populateCache |
merge function | Swaps the temporary item for the server’s version. |
rollbackOnError |
true |
Restores the pre-mutation data when the promise rejects. |
revalidate |
false |
The server response is authoritative; skip the confirming refetch. |
throwOnError |
true (default) |
Lets the caller show the error message. |
Gotchas & Edge Cases
- Concurrent mutations. Two quick posts each capture
currentwhen they start. With function-formoptimisticDataandpopulateCache, each operates on the latest cache state, so neither erases the other. Static objects would. - Rollback after a second edit. If a first mutation fails after a second one succeeded,
rollbackOnErrorrestores the snapshot taken before the first, which also removes the second. For high-frequency edits, disable rollback and remove only the failed item in acatchblock. - Moderated content. A comment saved as
pending_reviewshould stay visible to its author but not appear in the public thread returned by the CMS. Keep the author’s pending items in local state or a per-user key, or the next revalidation will remove them. - Webhooks echoing the write. If the CMS fires a publish webhook for the new entry and your SSE bridge calls
mutateon the thread key, the thread refetches right after the optimistic update. That is harmless whenpopulateCachealready stored the server version, but skip echoes for writes the current tab made by including a client id in the request. - Draft versus published writes. In-context editing tools must write to drafts and trigger a preview refresh, never publish directly from a public page. Route them through the preview proxy with editor authentication.
Verifying the Result
Throttle the network to “Slow 3G” in DevTools and post a comment: it should appear instantly with a pending style and settle a few seconds later without flicker or duplication. Make the server route return 422 and post again: the comment should disappear, the error should appear, and the textarea should still hold the text. A component test can drive the same scenarios with Mock Service Worker returning 201, 422 and a delayed 500.
Rollout Checklist
- Route every CMS write through a server endpoint that holds the write token and validates input.
- Use function forms of
optimisticDataandpopulateCache, never static objects. - Give optimistic items temporary ids and a pending flag the UI can style.
- Enable
rollbackOnErrorand keep the form input until the mutation settles. - Decide per feature whether the confirming refetch is needed, and disable it when the response is complete.
- Suppress webhook echoes for writes that originated in the same tab.
Frequently Asked Questions
Should editors’ inline changes use optimistic updates?
Yes, for the preview they are looking at, because waiting on a draft save makes editing feel sluggish. The write must go to the draft through the management API, and the published page should only change through the normal publish flow and its webhooks.
What if the CMS modifies the content on save?
Some CMS hooks sanitize HTML, trim whitespace or add computed fields. populateCache stores the server’s version, so the UI converges on what the CMS actually saved, which is why merging the response is better than keeping the optimistic value.
Can I use useSWRMutation instead?
useSWRMutation suits remote mutations that do not start automatically, and it accepts the same optimisticData, populateCache and rollbackOnError options. Use it when the mutation has its own loading state in the UI; the bound mutate shown here is enough for simple forms.
How do I prevent double submissions?
Disable the submit button while the mutation promise is pending, and make the server route idempotent with a client-generated request id stored on the entry. The optimistic item already gives immediate feedback, so a disabled button does not feel slow.