7 min read

Type‑Safe Isomorphic Data Fetching with React Server Components and SWR in Next.js

Learn how to combine React Server Components and SWR for a fully type‑safe, isomorphic data layer that works the same on the server and the client.
Type‑Safe Isomorphic Data Fetching with React Server Components and SWR in Next.js

Introduction

Next.js 13+ introduced React Server Components (RSC), a paradigm that lets you fetch data on the server and stream the rendered UI to the client without sending JavaScript for that part of the tree.
At the same time, SWR (stale‑while‑revalidate) remains the go‑to client‑side data‑fetching library because of its cache‑centric API, built‑in revalidation, and great support for TypeScript.

When building a modern web app you often need isomorphic data fetching: the same TypeScript types, the same validation logic, and the same error handling should be shared between the server‑rendered component tree and the client‑side interactive parts.

This article walks through a practical pattern that:

  1. Declares a single source of truth for request/response shapes using Zod (or any runtime schema).
  2. Uses the schema to generate type‑safe fetch helpers that work both in RSCs and in the browser.
  3. Wraps the client‑side helper with SWR so that the UI benefits from caching, revalidation, and focus‑based refetching.
  4. Shows how to hydrate the server‑side result into SWR’s cache to avoid a duplicate network request on the first client render.

The result is an isomorphic, type‑safe data layer that feels natural whether you are in a server component, a client component, or a plain utility module.

Prerequisites – Familiarity with Next.js 13+ App Router, React Server Components, TypeScript, and SWR. The code uses Zod for schema validation, but you can replace it with io-ts, runtypes, or any library that can produce both a TypeScript type and a runtime validator.

1. Define the contract with a runtime schema

Create a file that lives in src/lib/schemas.ts. It exports a Zod schema, the inferred TypeScript type, and a tiny wrapper that validates a raw JSON payload.

// src/lib/schemas.ts
import { z } from "zod";

export const postSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
  userId: z.number(),
});

export type Post = z.infer<typeof postSchema>;

Why a runtime schema?

  • Guarantees that the data you receive from an external API conforms to the expected shape.
  • The same schema can be used on the server (during fetch) and on the client (when SWR returns cached JSON).
  • It eliminates the need for duplicated interface declarations.

2. Build a type‑safe fetch helper

The helper lives in a module that is compiled for both Node.js (RSC) and the browser. It uses fetch directly, which is available in the Next.js runtime.

// src/lib/api.ts
import { postSchema, type Post } from "./schemas";

export async function fetchPost(id: number): Promise<Post> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    // In RSC we want to avoid the automatic cache‑busting that browsers do.
    // `next: { revalidate: 60 }` tells Next to cache the response for 60 seconds.
    next: { revalidate: 60 },
  });

  if (!res.ok) {
    // Throwing a regular error keeps the stack trace clean for both environments.
    throw new Error(`Failed to fetch post ${id}: ${res.status}`);
  }

  // Parse JSON *and* validate it against the Zod schema.
  const json = await res.json();
  const parsed = postSchema.parse(json);
  return parsed;
}

Key points:

  • next: { revalidate: 60 } works only in the server environment; the browser ignores it, so the same code runs everywhere.
  • The function returns a Promise<Post>, guaranteeing callers receive a fully typed object.
  • Any deviation from the schema throws at runtime, surface‑level bugs early.

3. Use the helper inside a React Server Component

// src/app/posts/[id]/page.tsx
import { fetchPost } from "@/lib/api";
import { PostView } from "./PostView";

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const post = await fetchPost(Number(params.id));

  // The component tree below runs on the server; no JavaScript is sent for it.
  return <PostView post={post} />;
}

PostView can be a client component if it needs interactivity (e.g., a like button). The server component fetches the data once, validates it, and streams the HTML to the client.


4. Re‑use the same helper with SWR on the client

Now create a client component that shows the same post but also supports live updates (e.g., a comment count).

// src/components/PostDetail.tsx
"use client";

import useSWR from "swr";
import { fetchPost } from "@/lib/api";
import { Post } from "@/lib/schemas";

type Props = {
  /** The post ID that was already rendered on the server */
  initialPost: Post;
  id: number;
};

export function PostDetail({ initialPost, id }: Props) {
  // SWR key must be stable – we use the ID.
  const { data, error, isValidating } = useSWR<Post>(["post", id], () =>
    fetchPost(id)
  , {
    fallbackData: initialPost, // <‑‑ Hydration step (see next section)
    revalidateOnFocus: true,
    dedupingInterval: 30_000,
  });

  if (error) return <p>❌ Failed to load post.</p>;
  if (!data) return <p>Loading…</p>;

  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
      {isValidating && <small>Refreshing…</small>}
    </article>
  );
}

Why this is type‑safe

  • The generic <Post> passed to useSWR tells TypeScript that data will be a Post object.
  • The fetcher fetchPost already returns Promise<Post>, so the types line up automatically.
  • No any or manual casting is required.

5. Hydrating SWR from the server‑rendered payload

Without hydration, the client would issue a second request for the same post, negating the benefit of server‑side rendering. The trick is to embed the server‑side result in the HTML and let SWR pick it up as its initial cache value.

5.1. Export a tiny wrapper that renders the client component

// src/app/posts/[id]/page.tsx (continued)
import { PostDetail } from "@/components/PostDetail";

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const post = await fetchPost(Number(params.id));

  return (
    <>
      {/* Server‑only UI, e.g., SEO meta tags */}
      <head>
        <title>{post.title}</title>
        <meta name="description" content={post.body.slice(0, 150)} />
      </head>

      {/* Client component receives the same data as `fallbackData` */}
      <PostDetail id={post.id} initialPost={post} />
    </>
  );
}

When Next.js streams the page, it serialises initialPost into a <script id="__NEXT_DATA__"> payload. SWR reads fallbackData on mount, so the first render already has the data and no network request is fired.

Tip: If you need the data in many places, consider creating a global SWR config in pages/_app.tsx (or app/layout.tsx) that sets fallback with the entire server‑side payload. This way every useSWR call can benefit automatically.

6. Handling errors and type‑narrowing

Because the fetch helper validates the response, you can rely on type narrowing after a successful call. For example, imagine an endpoint that sometimes returns { error: string } instead of a post.

// src/lib/schemas.ts
export const errorSchema = z.object({ error: z.string() });
export type ApiError = z.infer<typeof errorSchema>;

export const postOrErrorSchema = z.union([postSchema, errorSchema]);
export type PostOrError = z.infer<typeof postOrErrorSchema>;

Update the helper:

export async function fetchPost(id: number): Promise<Post> {
  const res = await fetch(...);
  const json = await res.json();

  // This will throw if the shape is neither Post nor ApiError.
  const parsed = postOrErrorSchema.parse(json);

  if ("error" in parsed) {
    // Convert API‑level errors into a thrown Error so callers use try/catch.
    throw new Error(parsed.error);
  }
  return parsed; // TypeScript now knows this is a Post.
}

The client component’s error state from SWR will now contain the thrown error, preserving a unified error handling strategy.


7. Extending the pattern – pagination and infinite loading

For lists, you generally want cursor‑based pagination. The same principles apply:

// src/lib/schemas.ts
export const postListSchema = z.object({
  items: z.array(postSchema),
  nextCursor: z.string().nullable(),
});

export type PostList = z.infer<typeof postListSchema>;
// src/lib/api.ts
export async function fetchPosts(cursor?: string): Promise<PostList> {
  const url = new URL("https://jsonplaceholder.typicode.com/posts");
  if (cursor) url.searchParams.set("cursor", cursor);

  const res = await fetch(url, { next: { revalidate: 30 } });
  const json = await res.json();
  return postListSchema.parse(json);
}

In a client component you can now use SWR’s useSWRInfinite:

import useSWRInfinite from "swr/infinite";
import { fetchPosts } from "@/lib/api";

function PostFeed() {
  const {
    data,
    error,
    size,
    setSize,
    isValidating,
  } = useSWRInfinite<PostList>(
    (pageIndex, previousPageData) => {
      // Stop when there is no next cursor.
      if (previousPageData && !previousPageData.nextCursor) return null;
      return ["posts", previousPageData?.nextCursor ?? null];
    },
    ([_, cursor]) => fetchPosts(cursor ?? undefined)
  );

  // Flatten pages into a single array
  const posts = data?.flatMap(page => page.items) ?? [];

  // Render UI …
}

All the type safety carries over: data is an array of PostList, each items field is Post[], and the cursor is correctly typed as string | null.


8. Testing the data layer

Because the fetch helpers are pure functions that return a typed promise, they are trivial to unit‑test.

// src/lib/api.test.ts
import { fetchPost } from "./api";
import { server, rest } from "msw";
import { setupServer } from "msw/node";

const api = setupServer(
  rest.get("https://jsonplaceholder.typicode.com/posts/:id", (req, res, ctx) => {
    const { id } = req.params;
    return res(
      ctx.json({ id: Number(id), title: "Test", body: "Body", userId: 1 })
    );
  })
);

beforeAll(() => api.listen());
afterEach(() => api.resetHandlers());
afterAll(() => api.close());

test("fetchPost returns a validated Post", async () => {
  const post = await fetchPost(42);
  expect(post.id).toBe(42);
  expect(post.title).toBe("Test");
});

The test ensures that the schema validation path is exercised, giving you confidence that runtime errors won’t slip into production.


9. Performance considerations

Concern Mitigation
Duplicate fetch on first render Hydrate SWR with fallbackData (as shown).
Large payloads sent to the client Keep the server component’s data minimal; fetch heavy data only in client components when needed.
Cache invalidation Use Next.js’s revalidate option for ISR, and SWR’s mutate to manually refresh after mutations.
Type‑only imports increase bundle size import type { Post } from "@/lib/schemas" removes runtime code from the client bundle.

10. Recap & best‑practice checklist

  • Single source of truth: Define request/response shapes with a runtime schema (Zod).
  • Typed fetch helper: Return Promise<YourType> and let the schema parse/validate.
  • Server component: Call the helper directly; the data is already type‑safe.
  • Client component: Wrap the same helper with SWR, passing the server result as fallbackData.
  • Hydration: Ensure the server‑rendered payload is available to SWR to avoid a duplicate request.
  • Error handling: Convert API‑level errors into thrown Errors; SWR’s error field will surface them.
  • Extendability: The pattern works for single resources, paginated lists, and even mutations (use mutate after a POST/PUT).

By following these steps you get:

  • Zero duplicated request on the initial load.
  • Full TypeScript inference from the moment the data leaves the network to the moment it is rendered.
  • Consistent validation across server and client, reducing runtime bugs.
  • A pleasant developer experience—the same fetchPost function is used everywhere.

Give it a try in a sandbox project, and you’ll quickly notice how much smoother the data flow feels when the type system, validation, and caching are all speaking the same language.