next-optimistic-router
v0.0.4
Published
> This library makes navigation truly instantaneous, without prefetching or caching. Also you can think about that library as a glue between next.js and tanstack-query/swr.
Downloads
16
Readme
next-optimistic-router
This library makes navigation truly instantaneous, without prefetching or caching. Also you can think about that library as a glue between next.js and tanstack-query/swr.
Installation
$ npm install next-optimistic-router
$ yarn add next-optimistic-router
Usage with @tanstack/react-query
1) Wrap _app into OptimisticRouterProvider
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import singletonRouter from 'next/dist/client/router';
import { handleOptimisticNavigation, OptimisticRouterProvider } from 'next-optimistic-router';
import { DehydratedState, HydrationBoundary, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React, { useLayoutEffect } from 'react';
export default function App({ Component, pageProps }: AppProps<{ dehydratedState: DehydratedState}>) {
const router = useRouter();
const [queryClient] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 1 * 60 * 1000,
gcTime: 5 * 60 * 1000,
}
}
}))
useLayoutEffect(() => {
// disable router prefetch completely
router.prefetch = async () => Promise.resolve(undefined);
router.beforePopState((state) => {
handleOptimisticNavigation({
href: state.as,
singletonRouter,
withTrailingSlash: Boolean(process.env.__NEXT_TRAILING_SLASH),
});
return true;
});
}, [router])
return (
<OptimisticRouterProvider singletonRouter={singletonRouter}>
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState} options={{
defaultOptions: {},
}}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
</OptimisticRouterProvider>
);
};
2) Wrap getServerSideProps/getStaticProps functions
Create a file withSSRTanStackQuery.ts
import type { ParsedUrlQuery } from 'querystring';
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
import { dehydrate, QueryClient } from '@tanstack/react-query';
function removeTrailingSlash(route: string) {
return route.replace(/\/$/, '') || '/'
}
const normalizeResolvedUrl = (resolvedUrl: string) => {
const pathnameAndQuery = resolvedUrl.split('?') as [string, string];
let pathname = removeTrailingSlash(pathnameAndQuery[0]);
let query = pathnameAndQuery[1];
if (process.env.__NEXT_TRAILING_SLASH && pathname !== '/') {
pathname = `${pathname}/`
}
if (query) {
const params = new URLSearchParams(query);
params.forEach((value, key) => {
if (key.startsWith('nxtP')) {
params.delete(key);
}
});
query = params.toString();
if (query)
return `${pathname}?${query}`
}
return pathname;
}
export const withSSRTanStackQuery = <T extends object, Q extends ParsedUrlQuery = ParsedUrlQuery>(getServerSideProps: GetServerSideProps<T, Q>) => async (
props: GetServerSidePropsContext<Q>,
) => {
const queryKey = normalizeResolvedUrl(props.resolvedUrl);
let result: GetServerSidePropsResult<T> | undefined = undefined;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: [queryKey],
queryFn: async () => {
result = await getServerSideProps(props) as { props: T | Promise<T> };
return result?.props;
}
})
if (!result) {
return;
}
if (!(result as { props: T | Promise<T> }).props) {
return result;
}
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
Wrap getServersideProps functions like this
export const getServerSideProps = withSSRTanStackQuery<ArticleItemApi, { slug: string }>(async ({ params }) => {
const { slug } = params ?? {};
try {
const article = await fetchArticle(slug);
return {
props: article,
}
} catch (e) {
return {
notFound: true,
}
}
})
Create a file withSSGTanStackQuery.ts
import { ParsedUrlQuery } from 'querystring';
import {
GetServerSidePropsResult,
GetStaticProps,
GetStaticPropsContext
} from 'next';
import { dehydrate, QueryClient } from '@tanstack/react-query';
function removeTrailingSlash(route: string) {
return route.replace(/\/$/, '') || '/'
}
const normalizePathname = (resolvedUrl: string) => {
const normalizedResolvedUrl = removeTrailingSlash(resolvedUrl);
let pathname = normalizedResolvedUrl.split('?')[0];
if (process.env.__NEXT_TRAILING_SLASH) {
pathname = `${pathname}/`
}
return pathname;
}
export const withSSGTanStackQuery = <T extends object, Q extends ParsedUrlQuery = ParsedUrlQuery>(getPath: (context: Q) => string, getStaticProps: GetStaticProps<T, Q>) => async (
props: GetStaticPropsContext<Q>,
) => {
const queryKey = normalizePathname(getPath(props.params!));
let result: GetServerSidePropsResult<T> | undefined = undefined;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: [queryKey],
queryFn: async () => {
result = await getStaticProps(props) as { props: T | Promise<T> };
return result?.props;
}
})
if (!result) {
return;
}
if (!(result as { props: T | Promise<T> }).props) {
return result;
}
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
And wrap getStaticProps functions like this
export const getStaticProps = withSSGTanStackQuery<ArticleItemApi, { slug: string }>(({ slug }) => `/blog/${slug}/`, async ({ params }) => {
const { slug } = params ?? {};
try {
const article = await fetchArticle(slug)
return {
props: article,
}
} catch (e) {
return {
notFound: true,
}
}
})
You must pass getPath function. That callback gets params as an argument and must return path of the page. In example above we return /blog/${slug}/
pages/
└── blog/
└── [slug]/
└── index.tsx
3) Create usePageData hook
import { DehydratedState, useQuery, useQueryClient } from '@tanstack/react-query';
import { usePageDataOptions } from 'next-optimistic-router';
import { useRouter } from 'next/router';
export const usePageData = <T>() => {
const queryClient = useQueryClient();
const router = useRouter();
const { queryKey, queryFn } = usePageDataOptions(router, Boolean(process.env.__NEXT_TRAILING_SLASH));
const placeholderData = typeof window === 'undefined' ? undefined : window.placeholderData;
const res = useQuery<unknown, unknown, T>({
queryKey,
queryFn: async () => {
const serverData = queryClient.getQueryData([...queryKey]);
if (serverData && !res.isStale) {
return serverData;
}
return queryFn().then((props) => {
const res = props as { dehydratedState: DehydratedState};
return res?.dehydratedState ? res.dehydratedState.queries[0].state.data : props;
})
},
placeholderData,
});
return res;
}
And use it to get data from getServerSideProps/getStaticProps functions
const { data: article, isLoading, isFetching, isStale} = usePageData<BlogItemPageProps>();
4) Create OptimisticLink component
import NextLink, { LinkProps } from 'next/link';
import React, { AnchorHTMLAttributes, MouseEvent, PropsWithChildren } from 'react';
import { handleOptimisticNavigation } from 'next-optimistic-router';
import singletonRouter from 'next/router';
type NextLinkProps = PropsWithChildren<Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
LinkProps>
type Props = NextLinkProps & {
placeholderData?: object;
}
export const Link = React.forwardRef<HTMLAnchorElement, Props>(function LinkComponent(props, ref) {
const {
placeholderData,
onClick,
href,
children,
...restProps
} = props;
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
if (onClick) {
onClick(e);
}
handleOptimisticNavigation({
href,
singletonRouter,
withTrailingSlash: Boolean(process.env.__NEXT_TRAILING_SLASH),
});
window.placeholderData = placeholderData;
}
return (
<NextLink
onClick={handleClick}
href={href}
prefetch={false}
ref={ref}
{...restProps}
>{children}</NextLink>
)
});
Use OptimisticLink component like regular next/link component