Is it normal to do an API request from nextjs?

Viewed 1444

Currently, I have my backend in NodeJS (Express) and also currently in process of migrating from create-react-app to NextJS on the frontend. Should I do API requests from the NextJS to the express server or should I move the APIs to the NextJS itself (with NextJS interacting with the database directly)? Is it normal to write the API component (the logic of interacting with the DB) in NextJS? Or should I go with the more traditional approach of NextJS interacting with my old express server?

Which of the two approaches is better in long run (I know writing the API component in NextJS is faster in terms of page loading speed, but would highly couple my frontend and backend logic):

  1. NextJS interacting with APIs written in a separate backend server (Express server)

  2. Move some/all of the backend logic to NextJS with NextJS directly interacting with Database

2 Answers

You should absolutely utilize the serverless node api routes. It is normal to use databases with nextjs. The serverless api routes in pages/api/** coupled with serverside functions in ./utils or ./lib (arbitrary preferences for naming these directories) are a fantastic duo for partitioning logic. For example, consider the following from a file in a project I'm working on for a client using booksy as a CMS currently. I had to reverse engineer their network auth flow and api routes to persistently serve data remotely.

import type { BooksyAuthResponse } from '@/types/index';
import { format } from 'date-fns';
import { parseUrl } from './helpers';

const EMAIL = process.env.BOOKSY_BIZ_EMAIL ?? "";
const PASSWORD = process.env.BOOKSY_BIZ_PASSWORD ?? "";
const API_KEY = process.env.NEXT_PUBLIC_BOOKSY_BIZ_API_KEY ?? "";
const FINGERPRINT =
    process.env.NEXT_PUBLIC_BOOKSY_BIZ_X_FINGERPRINT ?? "";

export const getAccessToken =
    async (): Promise<BooksyAuthResponse> => {
        const response = await fetch(
            `https://us.booksy.com/api/us/2/business_api/account/login?x-api-key=${API_KEY}&x-fingerprint=${FINGERPRINT}`,
            {
                method: 'POST',
                headers: {
                    'X-Api-Key': API_KEY,
                    'X-Fingerprint': FINGERPRINT,
                    'Content-Type': 'application/json',
                    'User-Agent':
                        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.152 Safari/537.36',
                    Connection: 'keep-alive',
                    Accept: '*/*',
                    'Accept-Encoding': 'gzip, deflate, br'
                },
                keepalive: true,
                body: JSON.stringify({
                    email: EMAIL!,
                    password: PASSWORD!
                })
            }
        );
        const authData = await response.json();
        return authData;
    };

// consumed in getStaticProps of pages/index.tsx
export const getLatestBooksyReviews = async ({
    reviewsPerPage,
    pageIndex
}: BooksyPagination): Promise<Response> => {
    const { access_token } = await getAccessToken();
    pageIndex = 1;
    reviewsPerPage = 10;
    return fetch(
        `https://us.booksy.com/api/us/2/business_api/me/businesses/481001/reviews/?reviews_page=${pageIndex}&reviews_per_page=${reviewsPerPage}`,
        Object.freeze({
            headers: {
                'X-Api-key': API_KEY,
                'X-Access-Token': `${access_token}`,
                'X-fingerprint': FINGERPRINT,
                Authorization: `s-G1-cvdAC4PrQ ${access_token}`!,
                'Cache-Control':
                    'public, s-maxage=86400, stale-while-revalidate=43200',
                'User-Agent':
                    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.152 Safari/537.36',
                Connection: 'keep-alive',
                Accept: '*/*',
                'Accept-Encoding': 'gzip, deflate, br'
            },
            method: 'GET',
            keepalive: true
        } as const)
    );
};

you can then consume the getAccessToken function in a serverless pages/api route to be used for clientSide fetching

import { NextApiRequest, NextApiResponse } from 'next';
import { BooksyReviewFetchResponse } from '@/types/booksy';
import fetch from 'isomorphic-unfetch';
import { getAccessToken } from '@/lib/booksy';

const API_KEY = process.env.NEXT_PUBLIC_BOOKSY_BIZ_API_KEY ?? '';
const FINGERPRINT =
    process.env.NEXT_PUBLIC_BOOKSY_BIZ_X_FINGERPRINT ?? '';

export default async function (
    req: NextApiRequest,
    res: NextApiResponse<BooksyReviewFetchResponse>
) {
    // console.log(req.headers);
    const {
        query: { reviews_page, reviews_per_page }
    } = req;

    const { access_token } = await getAccessToken();
    const rev_page_number = reviews_page ? reviews_page : 1;
    const reviews_pp = reviews_per_page ? reviews_per_page : 10;

    const response = await fetch(
        `https://us.booksy.com/api/us/2/business_api/me/businesses/481001/reviews/?reviews_page=${rev_page_number}&reviews_per_page=${reviews_pp}`,
        {
            headers: {
                'X-Api-key': API_KEY,
                'X-Access-Token': `${access_token}`,
                'X-fingerprint': FINGERPRINT,
                Authorization: `s-G1-cvdAC4PrQ ${access_token}`,
                'Cache-Control':
                    's-maxage=86400, stale-while-revalidate=43200',
                'User-Agent':
                    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.152 Safari/537.36',
                Connection: 'keep-alive',
                Accept: '*/*',
                'Accept-Encoding': 'gzip, deflate, br'
            },
            method: 'GET',
            keepalive: true
        }
    );
    // console.log(response.headers);
    const booksyReviews: BooksyReviewFetchResponse =
        await response.json();
    res.setHeader(
        'Cache-Control',
        'public, s-maxage=86400, stale-while-revalidate=43200'
    );

    return res.status(200).json(booksyReviews);
}

First, consume the getLatestBooksyReviews function in getStaticProps (server side) in pages/index.tsx (see const initialData). This is the same file that will be using the serverless booksy function on the client side in the default export

export async function getStaticProps(
    ctx: GetStaticPropsContext
): Promise<
    GetStaticPropsResult<{
        other: LandingDataQuery['other'];
        popular: LandingDataQuery['popular'];
        places: LandingDataQuery['Places'];
        merchandise: LandingDataQuery['merchandise'];
        businessHours: LandingDataQuery['businessHours'];
        Header: DynamicNavQuery['Header'];
        Footer: DynamicNavQuery['Footer'];
        initDataGallery: Partial<
            Configuration<Gallery, any, Fetcher<Gallery>>
        >;
        initialData: Partial<
            Configuration<
                BooksyReviewFetchResponse,
                any,
                Fetcher<BooksyReviewFetchResponse>
            >
        >;
    }>
> {
    const apolloClient = initializeApollo(
        { headers: ctx.params } ?? {}
    );
    await apolloClient.query<
        DynamicNavQuery,
        DynamicNavQueryVariables
    >({
        query: DynamicNavDocument,
        variables: {
            idHead: 'Header',
            idTypeHead: WordpressMenuNodeIdTypeEnum.NAME,
            idTypeFoot: WordpressMenuNodeIdTypeEnum.NAME,
            idFoot: 'Footer'
        }
    });
    await apolloClient.query<
        LandingDataQuery,
        LandingDataQueryVariables
    >({
        query: LandingDataDocument,
        variables: {
            other: WordPress.Services.Other,
            popular: WordPress.Services.Popular,
            path: Google.PlacesPath,
            googleMapsKey: Google.MapsKey
        }
    });

    const dataGallery = await getLatestBooksyPhotos();
    const initDataGallery: Gallery = await dataGallery.json();

    const dataInit = await getLatestBooksyReviews({
        reviewsPerPage: 10,
        pageIndex: 1
    });
    const initialData: BooksyReviewFetchResponse =
        await dataInit.json();
    return addApolloState(apolloClient, {
        props: { initialData, initDataGallery },
        revalidate: 600
    });
}

Now, the props returned on the server side in getStaticProps are seamlessly consumed on the client by the default export, it is injected as initial data for content to be displayed immediately in production on first load. Then, the client side stale while revalidate (SWR) hook fetches additional data for pagination from the serverless function. This creates a seamless UX. it caches the data, makes for a near instant experience

    const { data } =
        useSWR<BooksyReviewFetchResponse>(
            () =>
                `/api/booksy-fetch?reviews_page=${reviews_page}&reviews_per_page=${reviews_per_page}`,
            fetcher,
            initialData
        );
export default function Index<T extends typeof getStaticProps>({
    other,
    popular,
    Header,
    Footer,
    merchandise,
    places,
    businessHours,
    initialData,
    initDataGallery
}: InferGetStaticPropsType<T>) {
    const GalleryImageLoader = ({
        src,
        width,
        quality
    }: ImageLoaderProps) => {
        return `${src}?w=${width}&q=${quality || 75}`;
    };
    const reviews_per_page = 10;
    const [reviews_page, set_reviews_page] = useState<number>(1);
    const page = useRef<number>(reviews_page);
    const { data } =
        useSWR<BooksyReviewFetchResponse>(
            () =>
                `/api/booksy-fetch?reviews_page=${reviews_page}&reviews_per_page=${reviews_per_page}`,
            fetcher,
            initialData
        );
    const { data: galleryData } = useSWR<Gallery>(
        '/api/booksy-images',
        fetcherGallery,
        initDataGallery
    );

    // total items
    const reviewCount = data?.reviews_count ?? reviews_per_page;

    // total pages
    const totalPages =
        (reviewCount / reviews_per_page) % reviews_per_page === 0
            ? reviewCount / reviews_per_page
            : Math.ceil(reviewCount / reviews_per_page);

    // correcting for array indeces starting at 0, not 1
    const currentRangeCorrection =
        reviews_per_page * page.current - (reviews_per_page - 1);

    // current page range end item
    const currentRangeEnd =
        currentRangeCorrection + reviews_per_page - 1 <= reviewCount
            ? currentRangeCorrection + reviews_per_page - 1
            : currentRangeCorrection +
              reviews_per_page -
              (reviewCount % reviews_per_page);

    // current page range start item
    const currentRangeStart =
        page.current === 1
            ? page.current
            : reviews_per_page * page.current - (reviews_per_page - 1);

    const pages = [];
    for (let i = 0; i <= reviews_page; i++) {
        pages.push(
            data?.reviews ? (
                <BooksyReviews pageIndex={i} key={i} reviews={data.reviews}>
                    <nav aria-label='Pagination'>
                        <div className='hidden sm:block'>
                            <p className='text-sm text-gray-50'>
                                Showing{' '}
                                <span className='font-medium'>{`${currentRangeStart}`}</span>{' '}
                                to{' '}
                                <span className='font-medium'>{`${currentRangeEnd}`}</span>{' '}
                                of <span className='font-medium'>{reviewCount}</span>{' '}
                                reviews (page:{' '}
                                <span className='font-medium'>{page.current}</span> of{' '}
                                <span className='font-medium'>{totalPages}</span>)
                            </p>
                        </div>
                        <div className='flex-1 inline-flex justify-between sm:justify-center my-auto'>
                            <button
                                disabled={page.current - 1 === 0 ? true : false}
                                onClick={() => set_reviews_page(page.current - 1)}
                                className={cn('landing-page-pagination-btn', {
                                    ' cursor-not-allowed bg-redditSearch':
                                        reviews_page - 1 === 0,
                                    ' cursor-pointer': reviews_page - 1 !== 0
                                })}
                            >
                                Previous
                            </button>

                            <button
                                disabled={page.current === totalPages ? true : false}
                                onClick={() => set_reviews_page(page.current + 1)}
                                className={cn('landing-page-pagination-btn', {
                                    ' cursor-not-allowed bg-redditSearch':
                                        reviews_page === totalPages,
                                    ' cursor-pointer': reviews_page < totalPages
                                })}
                            >
                                Next
                            </button>
                        </div>
                    </nav>
                </BooksyReviews>
            ) : (
                <ReviewsSkeleton />
            )
        );
    }

    useEffect(() => {
        (async function Update() {
            return (await page.current) === reviews_page
                ? true
                : set_reviews_page((page.current = reviews_page));
        })();
    }, [page.current, reviews_page]);
    return (
        <>
            <AppLayout
                title={'The Fade Room Inc.'}
                Header={Header}
                Footer={Footer}
            >
                {galleryData?.images ? (
                    <Grid>
                        {galleryData.images
                            .slice(6, 9)
                            .map((img, i) => {
                                <GalleryCard
                                    key={img.image_id}
                                    media={galleryData}
                                    imgProps={{
                                        loader: GalleryImageLoader,
                                        width: i === 0 ? 1080 : 540,
                                        height: i === 0 ? 1080 : 540
                                    }}
                                />;
                            })
                            .reverse()}
                    </Grid>
                ) : (
                    <LoadingSpinner />
                )}
                {galleryData?.images ? (
                    <Marquee variant='secondary'>
                        {galleryData.images
                            .slice(3, 6)
                            .map((img, j) => (
                                <GalleryCard
                                    key={img.image_id}
                                    media={galleryData}
                                    variant='slim'
                                    imgProps={{
                                        loader: GalleryImageLoader,
                                        width: j === 0 ? 320 : 320,
                                        height: j === 0 ? 320 : 320
                                    }}
                                />
                            ))
                            .reverse()}
                    </Marquee>
                ) : (
                    <LoadingSpinner />
                )}
                <LandingCoalesced
                    other={other}
                    popular={popular}
                    places={places}
                    businessHours={businessHours}
                    merchandise={merchandise}
                >
                    {data?.reviews ? (
                        <>
                            <>{pages[page.current]}</>
                            <span className='hidden'>
                                {
                                    pages[
                                        page.current < totalPages
                                            ? page.current + 1
                                            : page.current - 1
                                    ]
                                }
                            </span>
                        </>
                    ) : (
                        <ReviewsSkeleton />
                    )}
                </LandingCoalesced>
            </AppLayout>
        </>
    );
}


Next.js is a front-end framework with a full-stack punch

you can also check out their express example

and a mongodb example (they also have a mysql example)

I don't think you need to convert your backend into the nextjs, use API requests, you will be fine? keep in mind the purpose of building a separate backend...

Related