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.

Optimistic write with rollbackThe component applies optimistic data to the SWR cache immediately, a server route calls the CMS management API, and on success the cache is reconciled with the server response; on failure SWR restores the previous data.ComponentSWR cacheServer routeManagement APImutate(key, promise, optimisticData)render optimisticstate at oncePOST /api/commentscreate entry (write token)201 entrysaved commentpopulateCachereplace optimistic
The reader sees the edit at once; the network result only decides whether it stays or rolls back.

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.

Cache states during an optimistic comment postThe thread cache moves from confirmed data to an optimistic state with a pending comment, then either to reconciled data with the server id or back to the original data on failure.Confirmed thread12 commentsOptimistic13, one pendingReconciled13, server idRolled back12 + error noticemutate2014xx or 5xx
The pending comment carries a temporary id so the reconciliation step can replace exactly that item.

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.

TypeScript
// 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:

TypeScript
// 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 current when they start. With function-form optimisticData and populateCache, 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, rollbackOnError restores the snapshot taken before the first, which also removes the second. For high-frequency edits, disable rollback and remove only the failed item in a catch block.
  • Moderated content. A comment saved as pending_review should 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 mutate on the thread key, the thread refetches right after the optimistic update. That is harmless when populateCache already 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.

Time until the comment is visibleTime from pressing Post to seeing the comment, measured on a throttled mobile profile, for refetch-after-write and optimistic mutation.Await write, then refetch1850 msAwait write, populate cache1120 msOptimistic mutate16 msThe optimistic path renders on the next frame; the write still takes about 1.1 s in the background.
Measured on DevTools Fast 3G throttling against a Strapi instance in the same region.

Rollout Checklist

  • Route every CMS write through a server endpoint that holds the write token and validates input.
  • Use function forms of optimisticData and populateCache, never static objects.
  • Give optimistic items temporary ids and a pending flag the UI can style.
  • Enable rollbackOnError and 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.