Server components, actions and the use API

Server and client components, streaming, useActionState and useOptimistic, reading promises with use, Suspense and error boundaries.

Server and client components

// app/guides/[slug]/page.tsx - a server component by default
async function GuidePage({ params }) {
  const guide = await loadGuide(params.slug)      // reads the database directly
  return (
    <article>
      <h1>{guide.title}</h1>
      <GuideBody markdown={guide.body} />
      <LikeButton id={guide.id} initialLikes={guide.likes} />
    </article>
  )
}

// app/guides/[slug]/LikeButton.tsx
'use client'                                      // this file and its imports run in the browser

import { useState } from 'react'

export function LikeButton({ id, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes)
  return <button type="button" onClick={() => setLikes(n => n + 1)}>{likes} likes</button>
}
CapabilityServer componentClient component
Reads a database, files or secretsYesNo
Uses state, refs or event handlersNoYes
Can be declared asyncYesNo
Adds JavaScript to the bundleNoYes
Renders on the server as HTMLYesYes, then hydrates
Imports the other kindCan render a client componentCannot import a server component, but can receive one as children

Push the 'use client' boundary as far down the tree as it will go. Every module inside the boundary ships to the browser, so a single directive at the top of a page moves the whole page into the bundle.

Actions, useActionState and useOptimistic

// app/guides/actions.js
'use server'      // every export becomes an endpoint the client may call

import { revalidatePath } from 'next/cache'

export async function likeGuide(previousState, formData) {
  const id = String(formData.get('id') ?? '')
  if (id === '') return { error: 'Missing id' }

  try {
    await db.guide.update({ where: { id }, data: { likes: { increment: 1 } } })
    revalidatePath('/guides/' + id)        // refresh the cached server output
    return { error: null }
  } catch {
    return { error: 'Could not save the like' }
  }
}
'use client'

import { useActionState, useOptimistic } from 'react'
import { likeGuide } from '../actions'

export function LikeButton({ id, initialLikes }) {
  const [likes, addOptimistic] = useOptimistic(initialLikes, (current, delta) => current + delta)
  const [state, formAction, isPending] = useActionState(likeGuide, { error: null })

  return (
    <form
      action={formAction}
      onSubmit={() => addOptimistic(1)}       // show the result before the server answers
    >
      <input type="hidden" name="id" value={id} />
      <button type="submit" disabled={isPending}>
        {likes} likes
      </button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  )
}
  • useActionState gives a form submission a state, a stable action and a pending flag, with no manual loading state.
  • useOptimistic renders the expected outcome immediately and reverts automatically when the action fails or the server data arrives.
  • An action's return value is the next state, which is why returning { error } is the pattern rather than throwing for expected failures.
  • revalidatePath (or its tag equivalent) is what makes the server-rendered output refresh after a mutation.

use, Suspense and error boundaries

import { Component, Suspense, use } from 'react'

// use reads a promise during render; the nearest Suspense boundary shows the fallback
function GuideTitle({ guidePromise }) {
  const guide = use(guidePromise)        // suspends until the promise resolves
  return <h1>{guide.title}</h1>
}

function Page({ params }) {
  // start the request before rendering the child, so the two overlap
  const guidePromise = loadGuide(params.slug)
  return (
    <Suspense fallback={<TitleSkeleton />}>
      <GuideTitle guidePromise={guidePromise} />
    </Suspense>
  )
}

// an error boundary catches what Suspense and the server cannot
class RouteErrorBoundary extends Component {
  state = { error: null }

  static getDerivedStateFromError(error) {
    return { error }
  }

  render() {
    if (this.state.error) return <ErrorPanel error={this.state.error} />
    return this.props.children
  }
}
  • Create the promise during render of a server component, not inside the component that reads it, so the request starts before the subtree suspends.
  • A Suspense boundary shows the fallback for its whole subtree; place boundaries around the slow part rather than the whole page.
  • Error boundaries must be class components or come from a library - there is no hook form. Catch render errors and nothing else: event handlers and async callbacks need their own try/catch.
  • Streaming means the shell is sent first and slow parts follow, so users see meaningful HTML while the data is still arriving.
⚠️
A server action is a public HTTP endpoint with a generated name. Validate every field, check authorisation on the server, and never trust an id sent from the browser - anyone can call it directly with a crafted request.

FAQ

Do server components replace an API?
For your own UI, largely yes: the component can read data directly, with no round trip and no serialisation step. Keep a real API when other clients, third parties or webhooks need the same data.
Why can a client component not import a server component?
The import would have to run in the browser, where the server-only code and its secrets are unavailable. Pass the server-rendered output down as children, which is serialised across the boundary.

Routing with React Router Production builds, environment config and deployment

Last refreshed 2026-09-18.