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:
- Predictable data dependencies: Because fragments are statically extracted by the Relay compiler, the server can know exactly what data a page needs before rendering.
- Single round-trip: Relay can collect all fragment data requirements into one query, eliminating waterfalls on the server.
- Hydration safety: The server can serialize the preloaded store and hydrate it on the client without re-fetching.
- SEO and performance: Critical HTML is delivered immediately, improving Largest Contentful Paint and crawlability.
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:
- The query runs only at build time, so the GraphQL server must be reachable during the build.
- The serialized records are baked into the static JSON, so the client never hits the server for the initial render.
- Use
fallback: "blocking"so ungenerated paths render on the first request and are then cached.
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
- Always run the Relay compiler before building. Add
relay-compilerto yourprebuildscript so generated artifacts exist before Next.js compiles. - Use a separate server environment per request. Sharing an environment across requests causes data leakage between users. Instantiate a fresh
Environmentin everygetServerSidePropscall. - Serialize only the records, not the environment. The
RecordSourceis plain JSON; the environment contains network handlers that are not serializable. - Prefer
usePreloadedQueryoveruseLazyLoadQueryfor SSR. Lazy queries trigger client-side fetches, defeating the purpose of server rendering. - Set appropriate
revalidatewindows. Too short and you lose the benefit of static caching; too long and content goes stale. Pair ISR with on-demand revalidation for content that changes unpredictably. - Avoid waterfalls in
getServerSideProps. If a page needs data from two independent queries, run them withPromise.allagainst the same environment. - Handle authentication carefully. Pass cookies or tokens from the incoming request context into your server-side fetch function so personalized data renders correctly.
- Monitor query complexity. Server preloading fetches the entire tree at once. Use Relay's
@argumentsand conditional fragments to avoid over-fetching on the server.
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.