Shared api library across Server + Client Components?
Unanswered
Chippiparai posted this in #help-forum
ChippiparaiOP
I am having trouble with creating an API wrapper that I can use to call from either front end or backend. I don't want to have to care about which context I call it from, however the fetch api call must be made from the Server side. So I wrote a wrapper library to be called from server functions but I get an error that Server functions cant be called during initital render. So how can I do this? I don't want to have to duplicate my wrapper and use a different function depending on server vs client component. (Also half the time I don't even have control of which it is, my client components still try to render on the server anyway)
40 Replies
ChippiparaiOP
I can share some code if that would help but I think my post explains what im going for. Theres something im fundamentally not getting about the server/client stuff because I can't imagine Next would be this popular with all the issues I have with this concept. I've read as much as I can find on how it works and it still isn't clicking
Black Turnstone
@Chippiparai can you share some code?
ChippiparaiOP
Yea i can im just not sure exactly what would specifically be helpful. My issue is i cant figure out how to call stuff that works the same way from both client or server
one sec
So here's my little API request wrapper:
"use server";
import { redirect } from "next/navigation";
import * as _ from "radash";
import { z } from "zod";
import { env } from "@/env";
import { auth0 } from "./auth0";
type RequestOptions = Omit<RequestInit, "method" | "headers" | "body"> & {
headers?: HeadersInit;
};
async function fetchWithAuth(
path: string,
init: RequestInit = {},
): Promise<Response> {
const session = await auth0.getSession();
console.log("session", session);
const headers: HeadersInit = {
Authorization: `Bearer ${session?.tokenSet.accessToken}`,
...(init.headers ?? {}),
};
console.log("backend url", env.BACKEND_URL + path);
const response = await fetch(env.BACKEND_URL + path, {
...init,
headers,
});
if (typeof window === "undefined" && response.status === 401) {
redirect("/auth/login");
}
return response;
}
async function handleResponse<T>(
response: Response,
schema: z.ZodType<T>,
): Promise<T | null> {
if (!response.ok) {
console.error("Response not OK:", response.status);
return null;
}
try {
const json: unknown = await response.json();
return schema.parse(json);
} catch (error) {
if (error instanceof z.ZodError) {
console.error("Schema validation error:", error);
} else {
console.error("Error parsing response:", error);
}
return null;
}
}
export async function get<T>(
path: string,
schema: z.ZodType<T>,
options?: RequestOptions,
): Promise<T | null> {
const headers: HeadersInit = {
credentials: "include",
...(options?.headers ?? {}),
};
const [err, response] = await _.try(fetchWithAuth)(path, {
...options,
headers,
});
if (err) {
console.error("Fetch error:", err);
return null;
}
return handleResponse(response, schema);
}
And im making calls from server actions using those:
"use server";
import { get } from "@/lib/api";
import { notificationsArraySchema } from "@/lib/notification.schema";
export async function getNotifications() {
return get(
"/notifications?dismissed_status=notDismissed",
notificationsArraySchema,
);
}
export async function getRecentNotifications() {
const now = new Date();
const oneMinuteAgo = new Date(now.getTime() - 60000).toISOString();
return get(
`/notifications?dismissed_status=notDismissed&age_timestamp=${oneMinuteAgo}`,
notificationsArraySchema,
);
}
Then i call this from my server side component:
const loadOptions = () =>
queryOptions({
queryKey: ["notifications"],
queryFn: async () => await getNotifications(),
refetchInterval: 60_000,
staleTime: 60_000,
});
export function useGetNotificationsSuspense() {
const { data: notifications, ...rest } = useSuspenseQuery(loadOptions());
return { notifications, ...rest };
}
// in component
const { notifications } = useGetNotificationsSuspense();
And i get these
So if i cant call these from server but i can in client, that means i have to specifically design my components in a different way depending on whether it will be server or client. I don't want to do that. I want to be able to use my api class the same way
Black Turnstone
Is there a reason for using useSuspenseQuery?
ChippiparaiOP
The intent I think is to preload the data on the server, then it refetches data every minute or so client side
Black Turnstone
Ooo, Got it
ChippiparaiOP
so this useSuspenseQuery i guess runs on the first server render, and then also again client side, so the call it makes needs to work in either environment
Black Turnstone
Lemme check
Black Turnstone
Modified Hook:
Client Component:
Server Component:-
Your custom api wrapper
get notification fn
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { getNotifications } from './notification';
export const loadOptions = () =>
queryOptions({
queryKey: ['notifications'],
queryFn: async () => {
const data = await getNotifications();
return data;
},
refetchInterval: 60_000,
staleTime: 60_000,
});
export function useGetNotificationsSuspense({
initialData,
}: {
initialData: string; // * Whatever is the initial data, fetched inside server component
}) {
return useSuspenseQuery({
...loadOptions(),
initialData,
});
}
Client Component:
'use client';
import { useGetNotificationsSuspense } from './use-notifications';
const Client = ({ initialData }: { initialData: string }) => {
const { data: notifications } = useGetNotificationsSuspense({
initialData,
});
return <div>Client, {notifications}</div>;
};
export default Client;
Server Component:-
import { unstable_cache } from 'next/cache';
import Client from './client';
import { getNotifications } from './notification';
const getNotificationsCache = unstable_cache(
getNotifications,
['notifications'],
{
revalidate: 60,
}
);
const Page = async () => {
const initialData = await getNotificationsCache();
return (
<>
Hello, Server Component
<Client initialData={initialData} />
</>
);
};
export default Page;
Your custom api wrapper
'use server';
async function get() {
console.log('Server only log');
return 'This is from server';
}
export default get;
get notification fn
'use server';
import get from './temp';
export async function getNotifications() {
return get();
}
@Chippiparai Just add initalData inside the useSuspenseQuery hook to prevent it from triggering before render.
ChippiparaiOP
I don't have a different client or server component though, its one or the other, depending on the need for any given page (if i have a form or something that needs context its a client, otherwise its probably server). My issue is that you have to define 2 separate ways to call the same API, using your getNotificationsCache() setup OR using the useSuspenseHook. I just want to define 1 way that works either way so i dont have to care whether im on the server or client
but even when i define a client component, it still gets rendered the first time on the server, so thats why the same code needs to work both sides, because i dont have control of where it will be run
Black Turnstone
React this -> https://github.com/TanStack/query/issues/6591
You will better understand about the error
According to someone in the comments
"suspense queries run on the server as well. This seems to be a nextjs design decision that you cannot call server actions during SSR. There isn't anything we can do here."
I think if you page is entirely client component then instead of useSuspenseQuery maybe use useQuery. If I will find better solution will tell
You will better understand about the error
According to someone in the comments
"suspense queries run on the server as well. This seems to be a nextjs design decision that you cannot call server actions during SSR. There isn't anything we can do here."
I think if you page is entirely client component then instead of useSuspenseQuery maybe use useQuery. If I will find better solution will tell
From what I have seen in my projects(with trpc + react query)
If I use any suspenseQuery. I have to prefetch that inside server component
If I use any suspenseQuery. I have to prefetch that inside server component
This is one example component
Server Page
Client Component
Here even, though my component is nested I still have to prefetch the initial data on server
Server Page
import { api, HydrateClient } from "~/trpc/server";
import { HomeView } from "~/modules/home/ui/views/home-view";
const Page = () => {
void api.categories.getMany.prefetch();
return (
<HydrateClient>
<HomeView />
</HydrateClient>
);
};
export default Page;
Client Component
"use client";
import { useQueryState } from "nuqs";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { FilterCarousel } from "~/components/filter-carousel";
import { api } from "~/trpc/react";
export const CategoriesSection = () => {
return (
<Suspense fallback={<FilterCarousel isLoading />}>
<ErrorBoundary fallback={<p>Error</p>}>
<CategoriesSectionSuspense />
</ErrorBoundary>
</Suspense>
);
};
const CategoriesSectionSuspense = () => {
const [categories] = api.categories.getMany.useSuspenseQuery();
const [categoryId, setCategoryId] = useQueryState("categoryId", {
history: "push",
});
const data = categories.map(({ name, id }) => ({
value: id,
label: name,
}));
const onSelect = (value: string | null) => {
void setCategoryId(value);
};
return (
<div>
<FilterCarousel value={categoryId} data={data} onSelect={onSelect} />
</div>
);
};
Here even, though my component is nested I still have to prefetch the initial data on server
ChippiparaiOP
Hmm ok i think something is clicking with the prefetch / suspense part, useSuspenseQuery is a hook and hooks only run on frontend. But you use the prefetch in the server hook to preload the data for that hook to use.
But still my same issue, the prefetch and suspense will call the same api function, and my api calls can only be run on the server as the API url its hitting is in an env var only available server side and this way it works with Auth0 session
But i cant use a server action for the prefetch because i get that error
Black Turnstone
You can try this also if possible
Here instead of passing initial data. I am just prefetching on server and adding a hydrationbounday from tanstack query
https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#prefetching-and-dehydrating-data
here is the docs for the same
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import Client from './client';
import { getQueryClient } from './query-client';
import { loadOptions } from './use-notifications';
const Page = async () => {
const queryClient = getQueryClient();
await queryClient.prefetchQuery(loadOptions());
return (
<>
Hello, Server Component
<HydrationBoundary state={dehydrate(queryClient)}>
<Client />
</HydrationBoundary>
</>
);
};
export default Page;
Here instead of passing initial data. I am just prefetching on server and adding a hydrationbounday from tanstack query
https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#prefetching-and-dehydrating-data
here is the docs for the same
ChippiparaiOP
Yea i need to try again with some of these changes but i think its still going to be mad about the call to get() inside that loadOptions, since its a server action
and it doesnt like calling the server action from the server (which makes no sense IMO)
Black Turnstone
https://github.com/mukund1606/react-query-use-suspense-query-hydration-example
I have created an example repo to showcase the above process check this if needed
I have created an example repo to showcase the above process check this if needed
ChippiparaiOP
Ok i think i see this working right, if you use the prefetch in the server and useSuspenseQuery in the client. But both are capable of hitting the server action, so thats an improvement on what i have
I think I can work with that, thanks dude for the effort
Black Turnstone
👍
@Chippiparai Ok i think i see this working right, if you use the prefetch in the server and useSuspenseQuery in the client. But both are capable of hitting the server action, so thats an improvement on what i have
Black Turnstone
Yes, I believe that's how it's supposed to work by the design of next.js
to me it looks natural
ChippiparaiOP
Yea it makes sense, I think theres just a disconnect in how our team is using next, we havent taken great care in intentioanlly doing something from client or server
Black Turnstone
Also, for the answer of this "(Also half the time I don't even have control of which it is, my client components still try to render on the server anyway)". Every component used inside routes untill mentioned explicitly will be rendered once on server. That's how it works under the hood
ChippiparaiOP
The issue ive had is that i assumed "Until mentioned explicity" meant if i put "use client" at the top
Black Turnstone
https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#with-no-ssr
Check this. I believe this is what pure client component will be without ssr
Check this. I believe this is what pure client component will be without ssr
ChippiparaiOP
Ive seen the dynamic import stuff, just looks messy so avoided it. but i think its clicking now anyway, ive got a lot of refactoring to do, not to mention the merge conflicts I will encounter by the time im done 🙃
Black Turnstone
All the best for that 😂
ChippiparaiOP
Not sure how to mark as answered lol