Integrating Next.js with GraphQL - Apollo and URQL on Server and Client

Integrating Next.js with GraphQL – Apollo and URQL on Server and Client

A modern GraphQL setup inside Next.js 15 using Apollo Client 4 and URQL 5.

Next.js matured from a pages-first React framework into a full-stack runtime with Server Components, Route Handlers, and Server Actions. That evolution makes GraphQL a natural fit: you can run a GraphQL server in-process (no extra infra), stream results into React trees, and choose exactly where caching lives. Since Next.js 15, GET Route Handlers and the client router cache are uncached by default, which reduces surprising staleness and gives you explicit control over revalidation for GraphQL traffic. As you’ll see, Apollo Client 4 and URQL 5 both ship first-class patterns for the App Router era, including streaming SSR for Apollo and an SSR exchange for URQL, so you can keep a single query across server and client with predictable cache semantics.

What you’ll build:

You’ll stand up a production-shaped GraphQL endpoint inside app/api/graphql/route.ts, then consume it from Server Components and Client Components using Apollo Client and URQL. We’ll cover authentication, cache & revalidation, mutations (including Server Actions), and subscriptions.

Assumption: TypeScript, Next.js 15, Node 18+, and a React server runtime (default in the App Router).

Step 1 — Host GraphQL inside Next.js

Why here: Route Handlers live in the App Router, so your GraphQL server shares the same runtime, env, and observability as your UI. You can export both GET and POST to support tools and playgrounds.

Option A: GraphQL Yoga (minimal, framework-agnostic).

app/api/graphql/route.ts

import { createSchema, createYoga } from 'graphql-yoga';

const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query { hello: String! }
      type Mutation { ping(message: String!): String! }
    `,
    resolvers: {
      Query: { hello: () => 'Hello from Yoga + Next.js' },
      Mutation: { ping: (_: unknown, { message }: { message: string }) => message },
    },
  }),
  graphqlEndpoint: '/api/graphql',
});

export { yoga as GET, yoga as POST };

Yoga cleanly maps to Next’s Web Request API and supports export { yoga as GET, yoga as POST } out of the box.

Option B: Apollo Server (if you need federation plugins, etc.).

app/api/graphql/route.ts

import { startServerAndCreateNextHandler } from '@as-integrations/next';
import { ApolloServer } from '@apollo/server';
import { gql } from 'graphql-tag';

const typeDefs = gql`
  type Query { hello: String! }
  type Mutation { ping(message: String!): String! }
`;
const resolvers = {
  Query: { hello: () => 'Hello from Apollo Server + Next.js' },
  Mutation: { ping: (_: unknown, { message }: { message: string }) => message },
};
const server = new ApolloServer({ typeDefs, resolvers });

export const GET = startServerAndCreateNextHandler(server);
export const POST = GET;

Apollo’s @as-integrations/next targets Route Handlers directly and works in the App Router.

Caching note. In Next.js 15, GET Route Handlers are uncached by default. If you later enable caching (e.g., for persisted queries), do it intentionally with Response headers or Next’s revalidate options.

Step 2 — Apollo Client 4 on the server and client

Apollo’s Next.js integration provides streaming SSR that plays nicely with Server Components and Suspense. The dedicated package is @apollo/client-integration-nextjs (formerly experimental-nextjs-app-support) and is now officially released.

Install

pnpm add @apollo/client @apollo/client-integration-nextjs graphql

Apollo Client 4 is the current major.

Wire the Provider (client boundary)

app/providers.tsx (Client Component)

'use client';

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { ApolloNextAppProvider } from '@apollo/client-integration-nextjs';

function makeClient() {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: '/api/graphql',
      // Avoid implicit caching during development; tune per route later.
      fetchOptions: { cache: 'no-store' },
      credentials: 'include',
    }),
    // Apollo defaults are fine; add typePolicies as your schema grows.
  });
}

export default function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}

app/layout.tsx (Server Component)

import Providers from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en"><body><Providers>{children}</Providers></body></html>
  );
}

The provider hydrates Apollo on the client without breaking Server Components.

Query in a Server Component

app/page.tsx (Server Component)

import { gql } from '@apollo/client';
import { getClient } from '@apollo/client-integration-nextjs/client';

const HELLO = gql`query { hello }`;

export default async function Page() {
  const client = getClient();
  const { data } = await client.query({
    query: HELLO,
    context: { fetchOptions: { next: { revalidate: 0 } } }, // explicit: no cache
  });

  return <pre>{data.hello}</pre>;
}

getClient() gives you a request-scoped client compatible with RSC and Suspense. You can stream results when queries stall, or opt into caching by setting revalidate.

Query in a Client Component

app/components/HelloClient.tsx (Client Component)

'use client';

import { gql, useQuery } from '@apollo/client';

const HELLO = gql`query { hello }`;

export function HelloClient() {
  const { data, loading, error } = useQuery(HELLO);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Oops: {error.message}</p>;
  return <p>{data.hello}</p>;
}

Mutations with or without Server Actions

For client-driven mutations, use useMutation and update the cache. For server-initiated writes (e.g., privileged headers), create a Server Action that calls the same GraphQL mutation and then returns updated UI—this integrates with Next’s cache invalidation.

// app/actions.ts
'use server';

import { gql } from '@apollo/client';
import { getClient } from '@apollo/client-integration-nextjs/client';

const PING = gql`mutation Ping($message: String!) { ping(message: $message) }`;

export async function sendPing(message: string) {
  const { data } = await getClient().mutate({ mutation: PING, variables: { message } });
  return data?.ping;
}

Step 3 — URQL 5 on the server and client

URQL focuses on a lightweight core with composable “exchanges.” For Next.js, you’ll use the SSR exchange and the small @urql/next helpers to hydrate client components cleanly. Version 5 is the current major.

Install

pnpm add urql @urql/next graphql

Build the client + SSR exchange

app/lib/urql.ts

import { cacheExchange, createClient, dedupExchange, fetchExchange, ssrExchange } from 'urql';

export function createUrql(ssr = ssrExchange()) {
  const client = createClient({
    url: '/api/graphql',
    exchanges: [dedupExchange, cacheExchange, ssr, fetchExchange],
    suspense: true, // better with RSC/Suspense
    fetchOptions: { credentials: 'include' },
  });

  return { client, ssr };
}

Provide in the App Router

app/urql-provider.tsx (Client Component)

'use client';

import { Provider as UrqlProvider } from 'urql';

export default function UrqlClientProvider({
  children,
  urqlState,
  url = '/api/graphql',
}: {
  children: React.ReactNode;
  urqlState: any;
  url?: string;
}) {
  // Recreate the SSR exchange on the client with initial state:
  const { createUrql } = require('./lib/urql');
  const { client, ssr } = createUrql();
  ssr.restoreData(urqlState);
  return <UrqlProvider value={client}>{children}</UrqlProvider>;
}

app/page.tsx (Server Component)

import UrqlClientProvider from './urql-provider';
import { createUrql } from './lib/urql';
import { gql } from 'urql';

const HELLO = gql`query { hello }`;

export default async function Page() {
  const { client, ssr } = createUrql();
  await client.query(HELLO, {}).toPromise(); // prefetch on server

  return (
    <UrqlClientProvider urqlState={ssr.extractData()}>
      {/* Client components under here can use useQuery without refetch */}
      <section>URQL is hydrated.</section>
    </UrqlClientProvider>
  );
}

URQL’s SSR docs show this pattern—prefetch on the server, extract, then restore on the client—so client hooks start warm.

Client query with URQL

app/components/HelloUrql.tsx (Client Component)

'use client';

import { gql, useQuery } from 'urql';

const HELLO = gql`query { hello }`;

export function HelloUrql() {
  const [{ data, fetching, error }] = useQuery({ query: HELLO });
  if (fetching) return <p>Loading…</p>;
  if (error) return <p>Oops: {error.message}</p>;
  return <p>{data.hello}</p>;
}

Authentication, headers, and secrets

Prefer routing sensitive headers through your Route Handler, not the browser. For Apollo Client, add auth via HttpLink or setContext; for URQL, use @urql/exchange-auth to attach and refresh tokens. When queries originate from Server Components or Server Actions, read cookies via next/headers and forward them to your GraphQL server.

// app/api/graphql/route.ts (example cookie passthrough)
import { cookies } from 'next/headers';
// ... inside Yoga/Apollo context creation:
const auth = cookies().get('session')?.value;

Caching and revalidation

With Apollo, choose between in-memory caching only on the client, or explicit RSC-level revalidation on the server. When you call client.query in a Server Component, opt into next: { revalidate: X } to cache per route. URQL uses a normalized document cache; its SSR exchange simply hydrates it on the client. Next.js 15’s shift to “uncached by default” for GET Route Handlers means your GraphQL endpoint won’t be accidentally memoized—add Cache-Control and revalidation windows intentionally if you implement persisted queries or public caching.

Mutations: client vs. server

Client-first: Wrap UX flows with useMutation (Apollo) or useMutation (URQL) and update caches optimistically for snappy feel.

Server-first: For privileged writes, a Server Action can call the same GraphQL mutation using your server-scoped client, then revalidate segments. Server Actions integrate directly with Next’s caching, so the UI and data update in a single roundtrip.

// Client UI calling a Server Action
'use client';
import { useTransition } from 'react';
import { sendPing } from '../actions';

export function PingForm() {
  const [pending, start] = useTransition();
  return (
    <form
      action={(formData) => start(() => sendPing(formData.get('message') as string))}
    >
      <input name="message" />
      <button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
    </form>
  );
}

Subscriptions

For local development or simple needs, mount WebSocket subscriptions beside your Route Handler using a WS server (e.g., graphql-ws) and have Apollo/URQL point to it with split links/exchanges. If you deploy to a serverless platform, prefer hosted WS (or SSE over HTTP/2) to avoid long-lived connection limits. (Implementation details vary per host; consult your platform’s WS guidance.)

Troubleshooting quick hits

  • 404 on /api/graphql: Ensure you export both methods from your handler: export { yoga as GET, yoga as POST }.
  • App Router + Apollo wiring: Use the Next.js integration package, not custom React wrappers; it exposes ApolloNextAppProvider and request-scoped clients.
  • URQL hydration gaps: Prefetch on the server, extractData(), then restoreData() in your provider; do not pass a live client instance across the RSC boundary.

When to pick Apollo vs. URQL

Pick Apollo if you want opinionated tooling, GraphOS integrations, and built-in streaming SSR with the official Next.js package. Pick URQL if you prefer a lean core with composable exchanges and explicit SSR hydration. Both integrate cleanly with the App Router; both let you run queries on the server and the client with one schema.

Was this helpful?

Thanks for your feedback!

Leave a comment

Your email address will not be published. Required fields are marked *