← Back to Blog

FastAPI + Next.js: RSC prefetch with a Python backend

·12 min read

Next.js App Router adds one capability that plain React doesn't have: Server Components can prefetch data before the page is sent to the browser. @pyrpc/next plugs directly into that pattern, api.prefetch warms the TanStack cache on the server, and api.HydrationBoundary hands it to the browser. Client components call useQuery and see instant data with no loading state.

Project layout

fastapi-nextjs/
  server/
    main.py               ← FastAPI app (identical to fastapi-react server)
    pyrpc.json
  client/
    lib/pyrpc.ts          ← createNextClient
    app/
      layout.tsx          ← RootLayout + Providers
      providers.tsx       ← "use client" QueryClient + api.Provider
      page.tsx            ← Server component: prefetch
      counter.tsx         ← Client component: useQuery/useMutation

The server (unchanged)

The FastAPI server is identical to the React example, the backend doesn't know or care which frontend framework you use.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pyrpc_core import rpc
from pyrpc_fastapi import mount_fastapi

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:3000"],
                   allow_credentials=True, allow_methods=["*"], allow_headers=["*"])

@rpc.query
def read_root(): return {"Hello": "World"}

@rpc.query
def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q}

@rpc.mutation
def create_item(name: str, description: str = None):
    return {"name": name, "description": description, "created": True}

mount_fastapi(app)

Client setup

// lib/pyrpc.ts
import { createNextClient } from "@pyrpc/next"
import type { Types } from "@pyrpc/types"

export const api = createNextClient<Types>({
  baseUrl: process.env.PYRPC_URL ?? "http://localhost:8000",
})
// app/providers.tsx
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { api } from "@/lib/pyrpc"
import { useState } from "react"

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider>{children}</api.Provider>
    </QueryClientProvider>
  )
}

Server component, prefetch

// app/page.tsx
import { api } from "@/lib/pyrpc"
import { Counter } from "./counter"

export default async function Page() {
  // warm the cache before the HTML is sent
  await api.prefetch.read_root()
  await api.prefetch.read_item({ item_id: 42, q: "test" })

  return (
    <api.HydrationBoundary state={api.dehydrate()}>
      <Counter />
    </api.HydrationBoundary>
  )
}

Client component, hooks

// app/counter.tsx
"use client"
import { api } from "@/lib/pyrpc"
import { useState } from "react"

export function Counter() {
  const [name, setName] = useState("")

  // already in cache, renders without a loading state
  const { data: greeting, isLoading } = api.read_root.useQuery()
  const { data: item } = api.read_item.useQuery({ item_id: 42, q: "test" })
  const createItem = api.create_item.useMutation()

  return (
    <div>
      {isLoading ? <p>Loading…</p> : <pre>{JSON.stringify(greeting)}</pre>}
      <pre>{JSON.stringify(item)}</pre>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={() => createItem.mutate({ name, description: `Item: ${name}` })}
              disabled={createItem.isPending}>
        {createItem.isPending ? "Creating…" : "Create"}
      </button>
      {createItem.isSuccess && <pre>{JSON.stringify(createItem.data)}</pre>}
    </div>
  )
}

Run it

# Terminal 1
cd server && uv add pyrpc-core[fastapi] && pyrpc dev

# Terminal 2
cd client && npm install && npm run dev

Open http://localhost:3000. The greeting and item data render instantly (no loading spinner) because the server prefetched them before shipping the HTML. The create form works client-side as a normal mutation.

When to skip prefetch

Prefetch is optional. If you don't call api.prefetch, useQuery fetches on mount exactly like plain React, still fully typed, just no server warm-up.