DEV Community

Petr Tcoi
Petr Tcoi

Posted on

NextJS API + React Query + Zod = type safety

One of the advantages of Next.js is the ability to combine frontend and backend within a single project and use shared types and interfaces. Unfortunately, the server-side part of the framework operates independently from the frontend, functioning as a largely standalone application. Therefore, ensuring type safety requires additional effort. In this article, I will share my experience in addressing this issue.

Folders

For each entity, a separate folder is created in which all methods related to it are defined.

│
├── database
│   ├── items
│   │   ├── api
│   │   │   ├── get-item-by-id
│   │   │   │   ├── getItemByIdClient.ts
│   │   │   │   ├── getItemByIdServer.ts
│   │   │   │   ├── config.ts
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── lib
│   │   │   ├── getItemById.ts
│   │   │   └── index.ts
│   │   ├── hooks
│   │   │   ├── use-get-item-by-id
│   │   │   │   ├── queryKey.ts
│   │   │   │   ├── useGetItemById.ts
│   │   │   │   └── index.ts
│   │   │   └── index.ts
│   │   ├── items.types.ts
│   │   └── index.ts
│   └── index.ts
Enter fullscreen mode Exit fullscreen mode

Let's go through these levels, starting from the base.

items.types.ts

Main File. This is where types related to the entity are defined: how they are stored in the database, how data is retrieved from the database, and the required fields for creation or editing.

To define the structure, Zod is used. In my last project, I used Strapi, so it looked something like this:

// How data is stored in the database
export type DbItem = z.infer<typeof zDbItem>;
export const zDbItem = z.object({
    id: z.number().int().positive(),
    name: z.string().nonempty({ message: "City name cannot be empty" }),
    sku: z.string().optional().or(z.null()),
    description: z.string().optional().or(z.null()),
});

// How data is returned from the database when queried
export type DbItemQuery = z.infer<typeof zDbItemQuery>;
export const zDbItemQuery = zDbItem.pick({ id: true }).merge(
    z.object({
        attributes: zDbItem.omit({ id: true, ...omitDates })
        })
);

// If needed, you can also define an object here for populating the response
export const itemQueryPopulate = {
    brand: { fields: ["id", "name"] },
    manufacturer: { fields: ["id", "name", "rating"] },   
}
Enter fullscreen mode Exit fullscreen mode

/lib - Database Interaction

This folder contains all the methods that will be used in the code. It is the only place where direct database interaction occurs.

In the method files, zod schemas and expected responses are defined. For example:

// .../lib/getItemById.ts

export type GetProps = z.infer<typeof zGetProps>;
export const zGetProps = z.object({
    id: z.coerce.number().int().positive(),
});

export type GetResult = z.infer<typeof zGetResult>;
export const zGetResult = zDbItemQuery;

export const getProductById = async ({ id }: GetProps): Promise<GetResult> => {
try {
        const query = await strapiSdk.findOne<DbItemQuery>(dbTables.items, id, {
            populate: itemQueryPopulate,
        });

        if (!query) {
            return fault({ code: ErrCode.CantFindError });
        }
        const { data } = z.object({ data: zDbItemQuery }).parse(query);

        return success(data);

        //catch
    } catch (error) {
        if (error instanceof ZodError) {
            return fault({ code: ErrCode.DbReturnWrongFormat });
        }
        return fault({ code: ErrCode.SomeError });
    }

}
Enter fullscreen mode Exit fullscreen mode

Now we have isolated methods in a separate folder for working with the database. If something changes in its logic or if you need to optimize a specific query, you can do it right here while preserving the argument and response types.

/api - Bridging Frontend and Backend

Functions in this folder are responsible for executing the application's business logic. Each feature is isolated in a separate folder and contains both a function that will be called on the frontend side (getItemByIdClient) and a function that will perform work on the server (getItemByIdServer).

api
 ├── get-item-by-id
       ├── getItemByIdClient.ts
       ├── getItemByIdServer.ts
       ├── config.ts
       └── index.ts
Enter fullscreen mode Exit fullscreen mode

The main file here is config.ts, which contains information about the types and interfaces used by the functions, as well as the path to the API route that will be called from the frontend.

// .../api/get-item-by-id/config.ts

import { z } from "zod";
import { paths } from "configs";

import { zGetResult, zGetProps } from "../../lib/getItemById";

// path
export const PATH = paths.api.items.root;

// type Props
export type TPropsServer = z.infer<typeof zPropsServer>;
export const zPropsServer = zGetProps;

export type TPropsClient = z.infer<typeof zPropsClient>;
export const zPropsClient = zPropsServer;

export type TResult = z.infer<typeof zGetResult>;
Enter fullscreen mode Exit fullscreen mode

Essentially, this is a set of re-exports needed to simplify code organization.

In this example, the arguments for both Client and Server are the same, but they can differ if, for instance, the Server function requires additional user ID obtained from cookies in the route.ts file and passed to the getItemByIdServer function.

Yes, the necessary code is located in another folder. Currently, this is the way it needs to be organized. When Server Actions are no longer experimental, you can eliminate this step. Additionally, I haven't tried the ts-rest library, which is aimed at solving a similar problem.

The file looks something like this:

// app/api/items/[id]/route.ts

export async function GET(req: NextRequest) {
    const { pathname } = new URL(req.url);
    const id = pathname
        .split("/")
        .filter(part => part !== "")
        .at(-1);

    const result = await getItemByIdServer({ id });

    return NextResponse.json(result, { status: result.statusCode });
}
Enter fullscreen mode Exit fullscreen mode

The getItemByIdServer function not only returns a response but also an HTTP status code:

// getItemByIdServer.ts

export default async function getItemByIdServer(props: TPropsServer): Promise<TResult> {
    const checkProps = zQueryProps.safeParse(props);

    if (!checkProps.success) {
        return({ code: "WrongData", message: checkProps.error }, HttpStatusCode.BadRequest);
    }

        const { data } = checkProps

    try {
        const result = await getCompanyById(data);
        if (isSuccess(result)) {
            return (result.payload, HttpStatusCode.Ok);
        }

        return ({ code: result.payload.code }, HttpStatusCode.BadRequest);

    } catch (e) {
        return({ code: ErrCode.UnknownError, message: "Неизвестная ошибка" }, HttpStatusCode.InternalServerError);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, instead of returning a regular error code, we throw an error because the function will be used in the useQuery hook from ReactQuery.

Both the server-side and client-side validate the received data using Zod.

/hooks - Adding ReactQuery

Hooks are placed in separate folders.

│   │   ├── hooks
│   │   │   ├── use-get-item-by-id
│   │   │   │   ├── queryKey.ts
│   │   │   │   ├── useGetItemById.ts
│   │   │   │   └── index.ts
Enter fullscreen mode Exit fullscreen mode

In the queryKey file, you simply define and export the key that will be used for this hook. It can be used in other parts of the application, for example, if you need to perform an invalidate operation for the query.

// .../queryKey.ts

export const QUERY_KEY = "item-get-by-id";
Enter fullscreen mode Exit fullscreen mode

The hook itself is simple - it only calls a previously created function for the frontend part:

import { useQuery } from "@tanstack/react-query";
import { GetItemByIdPropsClient, getItemByIdClient } from "shared/database";
import { QUERY_KEY } from "./queryKey";

export type UseGetItemProps = GetItemByIdPropsClient;

export const useGetItemById = ({ id }: UseGetItemProps) => {
    const queryKey = [QUERY_KEY, id];

    return useQuery(queryKey, () => getItemByIdClient({ id }), {
        suspense: true,
    });
};
Enter fullscreen mode Exit fullscreen mode

...and all of this is exported in the index.ts file:

// index.ts

export { QUERY_KEY as QUERY_KEY_GET_ITEM } from "./queryKey";
export { useGetItemById } from "./useGetItemById";
Enter fullscreen mode Exit fullscreen mode

Now, in your component, you can simply call the hook to retrieve the necessary data:

const { data: item } = useGetItemById({ id })
Enter fullscreen mode Exit fullscreen mode

Conclusion

In the example provided, it may seem like an excessive separation of a simple query into so many parts. However, this approach allows you to organize your code into isolated and reusable components, each focused on handling a specific level of abstraction.

As a result, understanding the code and conducting refactoring becomes easier, as it is immediately clear which types of tasks are being solved where. This modular structure also promotes code maintainability and scalability, making it easier to work on and extend the application over time.

Top comments (0)