← Back to DevBytes

Server-Side Rendering with Relay: SSR, SSG, ISR

Server-Side Rendering with Relay: SSR, SSG, ISR

Relay is Meta's GraphQL client, built specifically to handle data fetching at scale in React applications. While most Relay tutorials focus on client-side fetching, modern web apps increasingly demand server-rendered pages for performance, SEO, and time-to-interactive optimization. This tutorial walks through how to combine Relay with three rendering strategies: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — typically orchestrated through Next.js.

Why Server Rendering Matters with Relay

Relay's architecture is unique among GraphQL clients. It uses a compiler to generate query artifacts, fragments are co-located with components, and data is stored in a normalized store. This design has powerful implications for server rendering:

Prerequisites and Project Setup

You will need a GraphQL server exposing a schema, the Relay compiler configured in your project, and Next.js (App Router or Pages Router). This tutorial uses the Pages Router for clarity, but the patterns translate to the App Router with minor adjustments.

Install the required dependencies:

npm install react react-dom next relay-runtime react-relay graphql
npm install -D @types/react @types/react-relay babel-plugin-relay

Configure relay.config.json at the project root:

{
  "schema": "http://localhost:4000/graphql",
  "src": "./src",
  "language": "typescript",
  "artifactDirectory": "./src/__generated__",
  "exclude": ["**/node_modules/**", "**/__generated__/**"]
}

Add the Relay Babel plugin in .babelrc:

{
  "presets": ["next/babel"],
  "plugins": ["relay"]
}

Run the compiler in watch mode during development:

npx relay-compiler --watch

Building a Shared Relay Environment

Both server and client need a Relay environment, but they differ in network configuration. On the server, you typically point the network layer at an internal GraphQL endpoint and disable caching beyond the single request. On the client, you want a persistent store and proper cache invalidation.

// src/lib/relayEnvironment.ts
import {
  Environment,
  Network,
  RecordSource,
  Store,
  FetchFunction,
} from "relay-runtime";

const HTTP_ENDPOINT = "http://localhost:4000/graphql";

const fetchFn: FetchFunction = async (request, variables) => {
  const resp = await fetch(HTTP_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      query: request.text,
      variables,
    }),
  });
  return resp.json();
};

export function createEnvironment() {
  const network = Network.create(fetchFn);
  const store = new Store(new RecordSource());
  return new Environment({ network, store });
}

For the client, you want a singleton that survives across renders:

// src/lib/clientEnvironment.ts
import { useMemo } from "react";
import { Environment, RecordSource, Store, Network } from "relay-runtime";
import { RelayNetworkLayer } from "react-relay-network-modern";

let clientEnv: Environment | undefined;

export function getClientEnvironment() {
  if (typeof window === "undefined") return null;
  if (clientEnv) return clientEnv;

  const network = Network.create(async (request, variables) => {
    const res = await fetch("/api/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query: request.text, variables }),
    });
    return res.json();
  });

  clientEnv = new Environment({
    network,
    store: new Store(new RecordSource()),
    isServer: false,
  });
  return clientEnv;
}

export function useClientEnvironment(records: any) {
  const env = useMemo(() => {
    const existing = getClientEnvironment();
    if (existing) {
      existing.getStore().publish(new RecordSource(records));
      return existing;
    }
    return null;
  }, [records]);
  return env;
}

Server-Side Rendering (SSR)

SSR renders the page on every request, fetching fresh data each time. This is ideal for personalized or frequently updated content. The pattern is: create a server environment, preload the query, serialize the records, and pass them to the client for hydration.

First, define a query and a component:

// src/pages/product.tsx
import { graphql, usePreloadedQuery, PreloadedQuery } from "react-relay";
import type { product_Query } from "../__generated__/product_Query.graphql";

export const ProductQuery = graphql`
  query product_Query($id: ID!) {
    product(id: $id) {
      id
      name
      price
      description
    }
  }
`;

export default function ProductPage({
  prepared,
}: {
  prepared: { query: PreloadedQuery<product_Query> };
}) {
  const data = usePreloadedQuery(ProductQuery, prepared.query);
  return (
    <main>
      <h1>{data.product.name}</h1>
      <p>{data.product.description}</p>
      <strong>${data.product.price}</strong>
    </main>
  );
}

Next, create a helper that loads a query on the server and produces a serializable payload:

// src/lib/serverPreload.ts
import { createEnvironment } from "./relayEnvironment";
import { fetchQuery } from "relay-runtime";

export async function preloadQuery(query: any, variables: any) {
  const environment = createEnvironment();
  await fetchQuery(environment, query, variables).toPromise();
  const records = environment.getStore().getSource().toJSON();
  return { records, variables };
}

Wire it into getServerSideProps:

// src/pages/product.tsx (continued)
import { loadQuery } from "react-relay";
import { createEnvironment } from "../lib/relayEnvironment";
import { preloadQuery } from "../lib/serverPreload";

export async function getServerSideProps(context: any) {
  const { id } = context.query;
  const { records, variables } = await preloadQuery(ProductQuery, { id });

  return {
    props: {
      records,
      variables,
    },
  };
}

Finally, hydrate on the client using a custom RelayProvider wrapper:

// src/pages/_app.tsx
import { RelayEnvironmentProvider } from "react-relay";
import { useMemo } from "react";
import { Environment, RecordSource, Store, Network } from "relay-runtime";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  const environment = useMemo(() => {
    if (typeof window === "undefined") return null;
    const env = new Environment({
      network: Network.create(async (req, vars) => {
        const res = await fetch("/api/graphql", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ query: req.text, variables: vars }),
        });
        return res.json();
      }),
      store: new Store(new RecordSource(pageProps.records ?? {})),
    });
    return env;
  }, [pageProps.records]);

  if (!environment) return null;

  return (
    <RelayEnvironmentProvider environment={environment}>
      <Component {...pageProps} />
    </RelayEnvironmentProvider>
  );
}

Because the page component uses usePreloadedQuery, you need to convert the serialized records back into a preloaded query reference. A common approach is to wrap the page in a small client component that calls loadQuery on mount using the cached records:

// src/pages/product.tsx (final shape)
import { useEffect, useState } from "react";
import { loadQuery, useRelayEnvironment } from "react-relay";

export default function ProductPageWrapper(props: any) {
  const env = useRelayEnvironment();
  const [prepared, setPrepared] = useState(null);

  useEffect(() => {
    const queryRef = loadQuery(env, ProductQuery, props.variables);
    setPrepared({ query: queryRef });
  }, [env, props.variables]);

  if (!prepared) return <div>Loading...</div>;
  return <ProductPage prepared={prepared} />;
}

Static Site Generation (SSG)

SSG renders pages at build time. It is perfect for content that rarely changes: marketing pages, documentation, catalog listings. With Relay, the data fetching happens once during the build, and the resulting HTML plus serialized store is shipped as static assets.

Use getStaticProps instead of getServerSideProps. The preload logic is identical:

// src/pages/products/[id].tsx
import { graphql } from "react-relay";
import { preloadQuery } from "../../lib/serverPreload";

export const ProductStaticQuery = graphql`
  query productStatic_Query($id: ID!) {
    product(id: $id) {
      id
      name
      price
    }
  }
`;

export async function getStaticPaths() {
  // You can fetch a list of all product IDs here, or return empty paths
  // with fallback: "blocking" to generate pages on demand.
  return {
    paths: [],
    fallback: "blocking",
  };
}

export async function getStaticProps(context: any) {
  const { id } = context.params;
  try {
    const { records, variables } = await preloadQuery(ProductStaticQuery, { id });
    return {
      props: { records, variables },
      revalidate: 60,
    };
  } catch (err) {
    return { notFound: true };
  }
}

Key differences from SSR:

Incremental Static Regeneration (ISR)

ISR combines the speed of SSG with the freshness of SSR. Pages are generated statically, but Next.js re-renders them in the background when the revalidate window expires. The first visitor after staleness sees the cached version; subsequent visitors see the regenerated page.

The Relay setup for ISR is the same as SSG — the only addition is the revalidate value in getStaticProps. However, there are important nuances:

export async function getStaticProps(context: any) {
  const { id } = context.params;
  const { records, variables } = await preloadQuery(ProductStaticQuery, { id });

  return {
    props: { records, variables },
    revalidate: 300, // regenerate at most every 5 minutes
  };
}

For on-demand ISR, expose an API route that calls res.revalidate():

// src/pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const secret = req.headers["x-revalidate-secret"];
  if (secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  const path = req.query.path as string;
  try {
    await res.revalidate(path);
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).json({ message: "Revalidation failed" });
  }
}

When your GraphQL server mutates a product, it can POST to this endpoint to trigger regeneration immediately, rather than waiting for the revalidate window.

Handling Pagination and Fragments on the Server

Real pages rarely contain a single flat query. They use fragments spread across child components. Relay's compiler hoists fragment definitions into the parent query automatically, so server preloading still works — but you must ensure every fragment-bearing component is rendered within the same RelayEnvironmentProvider.

// src/components/ProductReviews.tsx
import { graphql, useFragment } from "react-relay";
import type { ProductReviews_product } from "../__generated__/ProductReviews_product.graphql";

const fragment = graphql`
  fragment ProductReviews_product on Product {
    reviews(first: 5) {
      edges {
        node {
          id
          author
          rating
          comment
        }
      }
    }
  }
`;

export default function ProductReviews({ product }: any) {
  const data = useFragment<ProductReviews_product>(fragment, product);
  return (
    <section>
      <h2>Reviews</h2>
      <ul>
        {data.reviews.edges.map(({ node }: any) => (
          <li key={node.id}>
            <strong>{node.author}</strong> — {node.rating}/5
            <p>{node.comment}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}

Update the page query to spread the fragment:

export const ProductQuery = graphql`
  query product_Query($id: ID!) {
    product(id: $id) {
      id
      name
      price
      description
      ...ProductReviews_product
    }
  }
`;

Because the fragment is part of the same query, the server preload fetches everything in a single request. The child component receives the fragment ref via props and renders synchronously during SSR.

Error Handling and Loading States

Server rendering changes how you handle errors. A failed query during getServerSideProps or getStaticProps should typically result in a 404 or 500 page, not a client-side error boundary. Wrap your preload in try/catch and return appropriate props:

export async function getServerSideProps(context: any) {
  const { id } = context.query;
  try {
    const { records, variables } = await preloadQuery(ProductQuery, { id });
    return { props: { records, variables } };
  } catch (err: any) {
    if (err?.response?.errors?.some((e: any) => e.message === "Not found")) {
      return { notFound: true };
    }
    throw err;
  }
}

For client-side refetches after hydration, use an ErrorBoundary around components that call useLazyLoadQuery or useRefetchableFragment.

Best Practices

App Router Considerations

If you are using the Next.js App Router, the same principles apply but the integration points differ. Server Components can fetch data directly without getServerSideProps. You can call fetchQuery inside a Server Component and pass the result down. Relay's usePreloadedQuery works in Client Components, while fetchQuery can be used in Server Components to populate the initial store.

// src/app/product/[id]/page.tsx
import { fetchQuery } from "relay-runtime";
import { createEnvironment } from "../../../lib/relayEnvironment";
import ProductView from "./ProductView";
import { ProductQuery } from "./ProductView";

export default async function Page({ params }: { params: { id: string } }) {
  const env = createEnvironment();
  await fetchQuery(env, ProductQuery, { id: params.id }).toPromise();
  const records = env.getStore().getSource().toJSON();

  return <ProductView records={records} variables={{ id: params.id }} />;
}

The ProductView Client Component then constructs its own environment from the serialized records and renders using usePreloadedQuery.

Conclusion

Server rendering with Relay unlocks the best of both worlds: the data colocation and compile-time safety of Relay, combined with the performance and SEO benefits of server-rendered HTML. SSR gives you fresh, personalized pages on every request; SSG gives you blazing-fast static pages built once; and ISR bridges the gap by regenerating pages in the background. The key to all three is the same: preload the query on the server using a fresh Relay environment, serialize the record source, and hydrate it on the client. By following the patterns and best practices in this tutorial, you can build Relay-powered applications that are fast, crawlable, and maintainable at scale.

— Ad —

Google AdSense will appear here after approval

← Back to all articles