Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime
Implement optimistic UI updates in Next.js with useOptimistic and Server Actions. Handle rollbacks, conflicts, and Supabase Realtime sync for instant-feeling interfaces.
Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime#
The difference between an app that feels fast and one that feels slow often isn't the actual latency — it's whether the UI responds before the server does. Optimistic updates show the expected result immediately, then reconcile with the server response. Done right, users barely notice network latency.
Next.js 15 ships useOptimistic as a stable React 19 hook, and it integrates cleanly with Server Actions. Combined with Supabase Realtime for multi-user sync, you can build interfaces that feel instant while staying consistent across clients.
Estimated read time: 13 minutes
Prerequisites#
- Next.js 15 (React 19) for stable
useOptimistic - Supabase project with Realtime enabled
- Familiarity with Server Actions and
useTransition - TypeScript recommended
How useOptimistic Works#
useOptimistic takes two arguments: the current real state, and an update function that produces the optimistic state. It returns the optimistic state (shown to the user) and a function to trigger an optimistic update.
const [optimisticState, addOptimistic] = useOptimistic(
realState,
(currentState, optimisticValue) => {
// Return the new optimistic state
return [...currentState, optimisticValue]
}
)
While a Server Action is in flight:
optimisticStateshows the optimistic value- When the action completes, React reverts to
realState(which should now reflect the server's response viarevalidatePathorrevalidateTag) - If the action throws, React reverts to the previous
realStateautomatically
Pattern 1: Optimistic List Item Addition#
The most common use case — adding an item to a list without waiting for the server.
// app/todos/TodoList.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { addTodo } from './actions'
interface Todo {
id: string
text: string
completed: boolean
pending?: boolean // flag for optimistic items
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
)
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string
if (!text.trim()) return
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`, // temporary ID
text,
completed: false,
pending: true,
}
startTransition(async () => {
addOptimisticTodo(optimisticTodo)
await addTodo(text)
})
}
return (
<div>
<form action={handleSubmit}>
<input name="text" placeholder="Add todo..." required />
<button type="submit" disabled={isPending}>Add</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li
key={todo.id}
style={{ opacity: todo.pending ? 0.6 : 1 }}
>
{todo.text}
{todo.pending && <span> (saving...)</span>}
</li>
))}
</ul>
</div>
)
}
// app/todos/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function addTodo(text: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Unauthorized')
const { error } = await supabase
.from('todos')
.insert({ text, user_id: user.id, completed: false })
if (error) throw error
revalidatePath('/todos')
}
When revalidatePath runs, Next.js re-fetches the Server Component data. The real todo (with a real ID from the database) replaces the optimistic one.
Pattern 2: Optimistic Toggle (Like / Complete)#
Toggles are the simplest optimistic pattern — the new state is the inverse of the current state.
// app/todos/TodoItem.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from './actions'
interface Todo {
id: string
text: string
completed: boolean
}
export function TodoItem({ todo }: { todo: Todo }) {
const [, startTransition] = useTransition()
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(
todo.completed,
(_, newValue: boolean) => newValue
)
function handleToggle() {
startTransition(async () => {
setOptimisticCompleted(!optimisticCompleted)
await toggleTodo(todo.id, !todo.completed)
})
}
return (
<li
onClick={handleToggle}
style={{
textDecoration: optimisticCompleted ? 'line-through' : 'none',
cursor: 'pointer',
}}
>
{todo.text}
</li>
)
}
The toggle feels instant. If the server action fails, optimisticCompleted reverts to todo.completed automatically.
Pattern 3: Optimistic Delete with Error Recovery#
Deletes need careful handling — if the delete fails, the item must reappear.
// app/todos/TodoList.tsx
'use client'
import { useOptimistic, useTransition, useState } from 'react'
import { deleteTodo } from './actions'
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const [optimisticTodos, removeOptimisticTodo] = useOptimistic(
initialTodos,
(state: Todo[], idToRemove: string) =>
state.filter((t) => t.id !== idToRemove)
)
function handleDelete(id: string) {
setError(null)
startTransition(async () => {
removeOptimisticTodo(id)
try {
await deleteTodo(id)
} catch (err: any) {
setError(`Failed to delete: ${err.message}`)
// useOptimistic auto-reverts — the item reappears
}
})
}
return (
<div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
The try/catch inside startTransition catches the Server Action error and shows it to the user. useOptimistic reverts the state automatically because the transition completed with the original state.
Pattern 4: Combining Optimistic Updates with Supabase Realtime#
In multi-user apps, you need both optimistic updates (for the current user) and Realtime updates (for other users). The challenge is avoiding duplicate updates.
// app/todos/RealtimeTodoList.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useOptimistic, useTransition, useEffect, useState } from 'react'
import { addTodo } from './actions'
export function RealtimeTodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos)
const [pendingIds] = useState(() => new Set<string>())
const [, startTransition] = useTransition()
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
)
// Subscribe to Realtime changes from OTHER users
useEffect(() => {
const supabase = createClient()
const channel = supabase
.channel('todos')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'todos' },
(payload) => {
const newTodo = payload.new as Todo
// Skip if this is our own optimistic update
if (pendingIds.has(newTodo.id)) {
pendingIds.delete(newTodo.id)
return
}
// Add todo from another user
setTodos((prev) => {
if (prev.find((t) => t.id === newTodo.id)) return prev
return [...prev, newTodo]
})
}
)
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [pendingIds])
async function handleAdd(formData: FormData) {
const text = formData.get('text') as string
const tempId = `temp-${Date.now()}`
const optimisticTodo: Todo = {
id: tempId,
text,
completed: false,
pending: true,
}
startTransition(async () => {
addOptimisticTodo(optimisticTodo)
const realId = await addTodo(text) // returns the real DB id
if (realId) pendingIds.add(realId) // mark to skip in Realtime handler
})
}
return (
<form action={handleAdd}>
<input name="text" />
<button type="submit">Add</button>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
{todo.text}
</li>
))}
</ul>
</form>
)
}
The pendingIds set tracks IDs of items we just inserted. When Realtime broadcasts the insert, we check if it's one of ours and skip it to avoid duplication.
[INTERNAL LINK: nextjs-supabase-realtime-collaboration]
Pattern 5: Optimistic Updates for Forms with Validation#
For forms with server-side validation, you want to show the optimistic state but handle validation errors gracefully:
// app/profile/ProfileForm.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { updateProfile } from './actions'
interface Profile {
displayName: string
bio: string
}
export function ProfileForm({ profile }: { profile: Profile }) {
const [, startTransition] = useTransition()
const [optimisticProfile, setOptimisticProfile] = useOptimistic(
profile,
(_, newProfile: Profile) => newProfile
)
async function handleSubmit(formData: FormData) {
const newProfile = {
displayName: formData.get('displayName') as string,
bio: formData.get('bio') as string,
}
startTransition(async () => {
setOptimisticProfile(newProfile)
const result = await updateProfile(newProfile)
if (result?.error) {
// Server action returned a validation error
// useOptimistic reverts automatically
// Show error to user via some state mechanism
}
})
}
return (
<form action={handleSubmit}>
<input name="displayName" defaultValue={optimisticProfile.displayName} />
<textarea name="bio" defaultValue={optimisticProfile.bio} />
<button type="submit">Save</button>
</form>
)
}
Common Pitfalls#
Not wrapping addOptimistic in startTransition. useOptimistic only works inside a React transition. Calling addOptimistic outside startTransition will throw in development and silently fail in production.
Using the temporary ID in subsequent operations. The optimistic item has a fake ID (temp-${Date.now()}). If the user tries to delete or update it before the real ID arrives, the operation will fail. Disable action buttons on items with pending: true.
Forgetting revalidatePath in the Server Action. Without revalidation, the Server Component data doesn't update after the action completes. The optimistic state reverts to the stale real state, making it look like the action failed.
Race conditions with rapid successive actions. If a user clicks "add" multiple times quickly, multiple optimistic updates queue up. Each one reverts independently when its action completes. This is usually fine, but test it explicitly.
Summary and Next Steps#
useOptimistic + Server Actions is the cleanest optimistic update pattern in the Next.js ecosystem. The key insight: optimistic state is temporary and automatically reverts — your job is to make the real state catch up via revalidatePath, and to handle errors gracefully so users know when something went wrong.
For multi-user apps, combine optimistic updates with Supabase Realtime and deduplicate events from your own mutations using a pending ID set.
Related reading:
- [INTERNAL LINK: nextjs-server-actions-supabase-complete-guide]
- [INTERNAL LINK: nextjs-supabase-realtime-collaboration]
- [INTERNAL LINK: nextjs-supabase-data-fetching-patterns]
Frequently Asked Questions
Related Guides
Next.js Server Actions with Supabase: Complete Production Guide
Complete guide to Next.js Server Actions with Supabase. Learn validation, error handling, optimistic updates, and production patterns for type-safe forms.
Building Real-Time Collaboration Features with Next.js and Supabase
Complete guide to building real-time collaborative applications with Next.js and Supabase. Learn presence tracking, live cursors, collaborative editing, real-time notifications, and conflict resolution.
AI Integration for Next.js + Supabase Applications
Complete guide to integrating AI capabilities into Next.js and Supabase applications. Learn OpenAI integration, chat interfaces, vector search, RAG systems,...