Cookie authentication for ApolloClient and NextJS app router

I am working with a NextJS project that needs to access a graphql API. I am using the nextjs app router and am trying to follow the best practices outlined here apollo-client-integrations/packages/nextjs/README.md at 1affb2b75c55ae2eeec4abf3833162a7d49a636c · apollographql/apollo-client-integrations · GitHub . However, I am running into some issues when it comes to authenticating with cookies.

A cookie is set when the user logs in, and the token from the cookie is inserted into the header of the graphql request before it is sent.

This is pretty simple in the RSC context:

// ApolloClient.ts

import { ApolloLink, from, HttpLink } from "@apollo/client";
import {
  ApolloClient,
  InMemoryCache,
  registerApolloClient,
} from "@apollo/client-integration-nextjs";
import { cookies } from "next/headers";

const authLink = new ApolloLink((operation, forward) => {
  operation.setContext(
    async ({ headers }: { headers: { [key: string]: string } }) => {
      const cookieStore = await cookies();
      const token = cookieStore.get("token")?.value;

      return {
        headers: {
          ...headers,
          authorization: token ? `Bearer ${token}` : "",
        },
      };
    },
  );
  return forward(operation);
});

const httpLink = new HttpLink({
  // this needs to be an absolute url, as relative urls cannot be used in SSR
  uri: "http://localhost:4000/gql",
  fetchOptions: {
    // you can pass additional options that should be passed to `fetch` here,
    // e.g. Next.js-related `fetch` options regarding caching and revalidation
    // see https://nextjs.org/docs/app/api-reference/functions/fetch#fetchurl-options
  },
});

export const link = from([authLink, httpLink]);

export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: link,
  });
});

However, things are not so simple when it comes to the Apollo client wrapper. In the NextJS middleware, all requests going to /graphql are proxied and forwarded to the external graphql server so we can get the token from the request’s cookies.

Here is the middleware:

// middleware.ts

import { type NextRequest, NextResponse } from "next/server";

export async function middleware(request: NextRequest) {
  const response = NextResponse.next({
    request,
  });

  const token = request.cookies.get("token")?.value;

  if (request.nextUrl.pathname === "/graphql" && request.method === "POST") {
    // Clone request and add Authorization header
    const modifiedRequest = token
      ? new Request(request, {
        headers: {
          ...Object.fromEntries(request.headers),
          Authorization: `Bearer ${token}`,
        },
      })
      : request;

    // Forward to External GraphQL server
    return NextResponse.rewrite(
      new URL("http://localhost:4000/gql"),
      {
        request: modifiedRequest,
      },
    );
  }

  return response;
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - images - .svg, .png, .jpg, .jpeg, .gif, .webp
     * Feel free to modify this pattern to include more paths.
     */
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};


Now here is where the problems start. In the ApolloWrapper, the URI needs to be an absolute URL because it runs in an SSR pass before being run in the browser. This creates a challenge because cookies are only included when the URL is a relative URL and it is run in the browser.

Here is the wrapper:

// ApolloWrapper.ts

"use client";

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

let link = new HttpLink({
  uri: "http://localhost:3000/graphql",
  credentials: "include",
});

function makeClient() {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link,
  });
}

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}

I tried formatting the link like the following:

const isServer = typeof window === "undefined";

let link = new HttpLink({
  uri: isServer ? "http://localhost:3000/graphql" : "/graphql",
  credentials: "include",
});

However, when running in the SSR pass, there are no cookies set because it is running on the server.

So I tried:

const isServer = typeof window === "undefined";

let link = new HttpLink({
  uri: "/graphql",
  credentials: "include",
});

if (isServer) {
  link = require("ApolloClient").link; // from first code snippet
}

This, of course, throws an error because the link from ApolloClient.ts that runs on the server references next/headers and ApolloWrapper is marked with “use client”.

Is there a better way to do this? Am I missing something here?

I guess a simpler version of this question is why are we breaking the browser client to make the initial SSR pass work when it doesn’t have the credentials to begin with? In ApolloWrapper.ts, if this client is supposed to be a browser client in, why is it supposed to work on the server as well? Isn’t that what the the ApolloClient.ts for RSC supposed to be for?

The problem here is how modern React SSR works:

  • you have an initial RSC pass that renders your server components
  • now you have a SSR pass of your Client components
  • and then you have a render pass in the browser that has to produce the same contents as in the server

Let me say - we can’t change any of that. That’s how React/Next.js works with RSC.

We have to find a pattern that fits it.

We could just “not fetch” during the SSR run, but that would essentially mean that you wouldn’t do SSR and end up with “loading” everywere in Google etc.
That would defeat the purpose of Next.js, which is before everything else is designed to be a SSR framework.

Now, your problem here is that requests made during SSR (and RSC) are not made from the browser (which knows your cookies) to the graphql server, but from the server (which doesn’t know your cookies). And an additional problem is the choice the Next.js team made (for whatever incomprehensible reason): you cannot access the request - and that means cookies - during the Client Component SSR run.

Now here is where the problems start. In the ApolloWrapper, the URI needs to be an absolute URL because it runs in an SSR pass before being run in the browser. This creates a challenge because cookies are only included when the URL is a relative URL and it is run in the browser.

This part is a misconception. It’s not about absolute or relative URLs. It’s about “the browser knows your cookie, the SSR run doesn’t”.

The solution for this is not straightforward, and it usually is something along the lines of “take the cookie you need during RSC, and pass it into the SSR pass”.

I did at one point create GitHub - phryneas/ssr-only-secrets: This package provides a way to pass secrets from Server Components into the SSR-run of Client Components, without them being accessible in the browser. and that will probably help you get to the point of solving this, but I’d look around a bit before that - this is a common problem with Next.js in general, and maybe someone has found a better way of doing this in the meantime?

An alternative (depending on application) might also be not rendering some of your client components that require authenticated data during SSR, or prefetching all data for your client components in RSC using PreloadQuery, because in that case your SSR run just wouldn’t need to make any requests - and in RSC, you have access to the request.