openapi-typescript icon indicating copy to clipboard operation
openapi-typescript copied to clipboard

With React Query: Invariant: headers() expects to have requestAsyncStorage, none available.

Open borzaka opened this issue 1 year ago • 3 comments
trafficstars

Description

My stack:

  • Next.js (App Router)
  • Auth.js
  • openapi-typescript
  • openapi-fetch
  • TanStack Query (React Query)

Reproduction

  • On the server side, the openapi-fetch works great: const settingsResponse = await client.GET("/api/settings/config-properties");
  • On the client side, it throws an error:
Error: Invariant: headers() expects to have requestAsyncStorage, none available.
    at headers (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/headers.js:38:15)
    at eval (webpack-internal:///(app-pages-browser)/./node_modules/next-auth/lib/index.js:97:84)
    at Object.onRequest (webpack-internal:///(app-pages-browser)/./src/lib/api/index.ts:11:78)
    at coreFetch (webpack-internal:///(app-pages-browser)/./node_modules/openapi-fetch/dist/index.js:100:32)
    at Object.GET (webpack-internal:///(app-pages-browser)/./node_modules/openapi-fetch/dist/index.js:163:14)
    at Object.queryFn (webpack-internal:///(app-pages-browser)/./src/components/project-card-list.tsx:38:83)
    at Object.fetchFn [as fn] (webpack-internal:///(app-pages-browser)/./node_modules/@tanstack/query-core/build/modern/query.js:195:27)
    at run (webpack-internal:///(app-pages-browser)/./node_modules/@tanstack/query-core/build/modern/retryer.js:92:31)
    at eval (webpack-internal:///(app-pages-browser)/./node_modules/@tanstack/query-core/build/modern/retryer.js:116:11)

My client component (components/project-card-list.tsx):

"use client";
...
function ProjectCardList() {
  const { isPending, error, data, isFetching } = useQuery({
    queryKey: ["getConfigProperties"],
    queryFn: async () => {
      const { data } = await client.GET("/api/settings/config-properties");
      return data;
    },
  });

  return (
    <div>
      {isPending && <div>Loading...</div>}
      {error ? (
        <div>There was an error: {error.message}</div>
      ) : (
        <pre>
          <code>{JSON.stringify(data, undefined, 2)}</code>
        </pre>
      )}
    </div>
  );
}

export default ProjectCardList;

My openapi-fetch client with Middleware (lib/api/index.ts):

import createClient, { type Middleware } from "openapi-fetch";

import { auth } from "@/auth";

import type { paths } from "./api-docs";

let accessToken: string | undefined = undefined;

const authMiddleware: Middleware = {
  async onRequest(request) {
    if (!accessToken) {
      const session = await auth();

      if (!session?.user.accessToken) {
        return undefined;
      } else {
        accessToken = session.user.accessToken;
      }
    }

    request.headers.set("Authorization", `Bearer ${accessToken}`);
    return request;
  },
  async onResponse(response) {
    if (response.status >= 400) {
      const body = response.headers.get("content-type")?.includes("json")
        ? await response.clone().json()
        : await response.clone().text();
      throw new Error(body);
    }
    return undefined;
  },
};

const client = createClient<paths>({
  baseUrl: process.env.API_BASE_URL,
  headers: {
    Accept: "application/json",
  },
});
client.use(authMiddleware);

export default client;

I have followed the examples here for Next.js App Router to create the QueryClientProvider: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr

It works fine with native fetch:

const { data, status } = useQuery({
  queryKey: ["episodes"],
  queryFn: async () => {
    return await fetch("https://rickandmortyapi.com/api/episode").then(
      (response) => response.json(),
    );
  },
});

What am I missing?

Expected result

Work as intended in client component with React Query.

Checklist

borzaka avatar Apr 29 '24 15:04 borzaka

I have figured out. The useQuery's queryFn needs to be on the server side.

actions.ts:

"use server";

export const fetchSettings = async () => {
  const { data } = await client.GET("/api/settings/config-properties");
  return data;
};

My client component:

"use client";

import { fetchSettings } from "@/lib/actions";
import { useQuery } from "@tanstack/react-query";

const { isPending, error, data, isFetching } = useQuery({
  queryKey: ["getConfigProperties"],
  queryFn: async () => fetchSettings(),
});

I don't know why needed like this in my case, but maybe worth a note somewhere in the docs. Hope it helps someone.

borzaka avatar Apr 30 '24 15:04 borzaka

@borzaka That’s a good find! Maybe this would be a good addition to examples/next (since the React Query example is currently only client-side)? Or even a new examples/next-react-query would be great as well if you’re able to provide it

drwpow avatar Apr 30 '24 16:04 drwpow

  • The Next.js example is on the server side, so no problem there with openapi-typescript alone without React Query.
  • The React Query example is on the client side, but I have no idea why works there.

Every other example I found with Next.js and React Query, the queryFn was on the server side. Like in this: https://github.com/developedbyed/next14-query-combo-cache-destroyer/tree/master

app/page.tsx

import PostForm from "@/components/post-form"
import Posts from "@/components/posts"
import { fetchPosts } from "@/server/actions/create-post"
import {
  QueryClient,
  HydrationBoundary,
  dehydrate,
} from "@tanstack/react-query"

export default async function Home() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  })
  return (
    <main>
      <HydrationBoundary state={dehydrate(queryClient)}>
        <PostForm />
        <Posts />
      </HydrationBoundary>
    </main>
  )
}

server/actions/create-post.ts

"use server";

export const fetchPosts = async () => {
  const posts = await db.query.posts.findMany({
    with: {
      author: true,
      likes: true,
    },
    orderBy: (posts, { desc }) => [desc(posts.timestamp)],
  })
  if (!posts) return { error: "No posts 😓" }
  if (posts) return { success: posts }
}

And I also have problem to create type interface, to pass down props to the server side.

borzaka avatar May 01 '24 13:05 borzaka

Hey @borzaka ! This issue starts to be a bit old but if your problem is still not resolved, here is a quick explanation why you have this behavior:

When working with Next.JS, the boundaries between frontend and backend become blurry. useQuery is a client-side only function but your openapi-fetch client can only be run on the server.

As shown in lib/api/index.ts, the openapi-fetch client will only work on the server for two reasons :

  1. Your onRequest middleware is using what looks like to be a server only function const session = await auth();. There is a good chance that auth() calls the function headers() that is only available on the server. See https://nextjs.org/docs/app/api-reference/functions/headers. This is what the error is about.
  2. You are using process.env.API_BASE_URL which will result in undefined as you have to prefix env variables with NEXT_PUBLIC_ to be available on the client. See https://nextjs.org/docs/app/building-your-application/configuring/environment-variables

And with the following code, it will try to instantiate your openapi-fetch client in the browser because it is inside a client component. (useQuery is client side only as hooks can only work client side).

"use client";
...
function ProjectCardList() {
  const { isPending, error, data, isFetching } = useQuery({
    queryKey: ["getConfigProperties"],
    queryFn: async () => {
      const { data } = await client.GET("/api/settings/config-properties");
      return data;
    },
  });
  ...
}

You have two solutions:

  • Make your openapi-fetch client work on the client. (or create a new one that can be used client side).
  • Use your openapi-fetch client on the server using Server actions.

The solution depends on your goal, whether you want your user to query directly your API from the browser, or you want the request to be proxied from your Next server using a Server action.

kerwanp avatar Jun 24 '24 10:06 kerwanp

Thank you for your explanation @kerwanp!

I ended up using like below in my Next.js project, which solved all of my problems;

  • the original headers() problem (solution: the fetch should be on the server side)
  • error message sanitization done by Next.js on the server side (solution: error throwing on the client side, not in the Middleware)
"use client";

import { getConfigProperties } from "@/lib/actions";

...

const {
  data: configProperties,
  isPending,
  isError,
  error,
} = useQuery({
  queryKey: ["getConfigProperties"],
  queryFn: async () => {
    const { data, error } = await getConfigProperties();
    if (error) {
      throw new Error(error.message);
    }
    return data;
  },
});

...

actions.ts:

"use server";

export async function getConfigProperties() {
  const { data, error } = await client.GET("/api/settings/config-properties");

  return { data, error };
}

...

borzaka avatar Jun 24 '24 14:06 borzaka

@borzaka so you went for solution 2! Just be aware that this will consume your Edge requests credits (and computation time) if you host your app on Vercel has it does a hop through your Next backend Browser -> Next Backend -> API.

Feel free to close this issue if it has been solved.

kerwanp avatar Jun 24 '24 14:06 kerwanp