Part 2 – Full Stack Todos Application with NextJS, Prisma (using SQL Server), and Redux Toolkit (RTK)

The purpose of this document series is to describe the steps that are necessary to create a “Todos” application using NextJS, Prisma, and Redux Toolkit (RTK).

NextJS is an exciting development tool to create web applications because they allow your Server Code and Client Code to be in the same repository. It is essentially like having a NodeJS server application and REACT Application in the same repository.

TLDR, show me the code: https://github.com/woodman231/nextjs-prisma-todos-rest

TOPIC: Configure the REST Server Features

I will be organizing the code in such a way that features of the application will be in feature directories and provide models and code for other services throughout the application.

Configure the “common” features

To the “next-app” directory create a new directory called: “features”. To the “features” directory create a new directory called “common”.

Configure the re-usable PrismaClient

The first thing that we will need to interact with the database is an instance of the PrismaClient. We also do not want to create more than one instance of the client as it does do its own connection pooling. This will prevent us from having to manually connect and disconnect from the database.

Create a new directory called: “prisma” inside of this “common” directory. Inside the “prisma” directory create a new file called: “prismaClient.ts”. Give it the following code:

import { PrismaClient } from "@prisma/client";

let applicationPrismaClient: PrismaClient | null = null;

function getPrismaClient() {
    if (!applicationPrismaClient) {
        applicationPrismaClient = new PrismaClient();
    }

    return applicationPrismaClient;
}

export const prismaClient = getPrismaClient();
Essentially what we are doing here is setting the variable that will be storing the PrismaClient instance to null. Then when it is first called it creates an instance of it. Later calls to this function will return the initialized variable.

Configure the common parameter validators

We will be validating requests like /api/todos/1 often. But what if someone tries to go to /api/todos/one? Well as we know “one” is not a number, and therefore not valid to pass along as a where clause to the sql server. So, let’s set up a common way to handle that.

Create a “params” directory within the “common” directory. Create an “idParams.ts” file and give it the following code:

export interface IDParams {
    params: {
        id: string;
    }
}
Create a new directory under “common” called: “paramValidators”. To the “paramValidators” directory create a new file called: “idParamaterValidator.ts”. Give it the following code:
import { IDParams } from "../params/idParams";

export const idParamaterValidator = ({ params }: IDParams): { isValid: boolean, errorMessage?: string } => {
    const id = Number(params.id);
    if (Number.isNaN(id)) {
        return { isValid: false, errorMessage: `${params.id} is not a number` };
    }

    return { isValid: true };
}

Configure the common REST Response Models, and REST Responses

In this application I want to have every REST Response return with either a data key, or an error key. The data will be of a type that will be defined later, and the error will just have a message key inside of it.

Create a new directory inside of common called: “restResponseModels”. Create a new file called: “restApplicationErrorRespoinseModel.ts”. Give it the following code:

export interface RestApplicationErrorResponseModel {    
    message: string;    
}
Create a new fille in the “restResponseModels” directory called: “restApplicationResponseModel.ts”. Give it the following code:
import { RestApplicationErrorResponseModel } from "./restApplicationErrorResponseModel";

export interface RestApplicationResponseModel<T> {
    data?: T;
    error?: RestApplicationErrorResponseModel;
}
Depending on your experience level with TypeScript that “T” might look new to you. Essentially what we are doing is taking a Type as a parameter and using that Type as the Type that the data key will be. When we use this model we will be doing things like the following code (this is just some example code, and does not belong anywhere in the solution).
interface SomeDataType {
    someData: string;    
}

let results: RestApplicationResponseModel<SomeDataType> = {};
let errorOccured = false;

if (errorOccured) {
    results.error = {
        message: "some error"
    }
} else {
    results.data = {
        someData: "some data"
    }
}
Errors do occur and can be part of our responses. For NextJS we just need to return a NextResponse with some optional data and a status code. Since we already setup that we will be responding with an object that can have an error key with a message key inside of it, let’s put together an error response builder function to be used with some of the various errors we can expect to have to return from our application.

Create a new directory within the common directory called: “restResponses”. Create a new file called: “restErrorResponseBuilder.ts”. Give it the following code:

import { StatusCode } from "status-code-enum"
import { NextResponse } from "next/server"
import { RestApplicationErrorResponseModel } from "../restResponseModels/restApplicationErrorResponseModel"
import { RestApplicationResponseModel } from "../restResponseModels/restApplicationResponseModel"

export function restErrorResponseBuilder(initialMessage: string, statusCode: StatusCode): (additionalDetails?: string) => NextResponse {
    return function <T>(additionalDetails?: string): NextResponse {
        let finalMessage = initialMessage;

        if (additionalDetails) {
            finalMessage += `: ${additionalDetails}`
        }

        const errorMessage: RestApplicationErrorResponseModel = {
            message: finalMessage,
        }

        const restResponse: RestApplicationResponseModel<T> = {
            error: errorMessage
        }

        return NextResponse.json(restResponse, { status: statusCode })
    }
}
Essentially this is a function that returns a function. We are also using type parameters again. Essentially the builder provides the initial error message, and status code. The internal function allows the developer to optionally give some additional details. If there are additional details supplied then the final error message will be a concatenation of the “initialMessage” and the “additionalDetails”. Otherwise the error message is just the initial message. The NextResponse is returned with the appropriate status code.

Now that we have a common way to build errors. Let’s build a couple of them that we will be using.

In this “restResponses” directory create a new file called: “badRequestErrorResponse.ts” and give it the following code:

import { StatusCode } from "status-code-enum"
import { restErrorResponseBuilder } from "./restErrorResponseBuilder";

export const badRequestErrorResponse = restErrorResponseBuilder("Bad Request", StatusCode.ClientErrorBadRequest)
As mentioned earlier we will be using Zod for schema validation. In our application that mostly means we will be validating the input from the user via REST Request Bodies. Let’s build a common way to convert the issues from Zod to Bad Request REST Responses.

Create a new file called: “badRequestErrorResponseFromZodIssues.ts” and give it the following code:

import {ZodIssue} from "zod";
import {badRequestErrorResponse} from "./badRequestErrorResponse";

export const badRequestErrorResponseFromZodIssues = (issues: ZodIssue[] | undefined) => {
    if(issues) {
        const additionalDetails: string[] = [];
        issues.forEach(issue => {
            if (issue.code === "invalid_union") {
                const { unionErrors } = issue;
                if (unionErrors) {
                    unionErrors.forEach(unionError => {
                        unionError.issues.forEach(issue => {
                            const errorMessageString = `${issue.message} for ${issue.path.join(".")}`;
                            if (!additionalDetails.includes(errorMessageString)) {
                                additionalDetails.push(`${issue.message} for ${issue.path.join(".")}`);
                            }
                        });
                    });
                }
            } else {
                const errorMessageString = `${issue.message} for ${issue.path.join(".")}`;
                if (!additionalDetails.includes(errorMessageString)) {
                    additionalDetails.push(`${issue.message} for ${issue.path.join(".")}`);
                }
            }
        });
    
        return badRequestErrorResponse(additionalDetails.join(". "));
    } else {
        return badRequestErrorResponse();
    }
}
Sort of a lot going on here, but essentially, we are using the badRequestError that we created earlier, and just looping over all of the Zod issues to create one cohesive string of “additionalDetails” for that badRequestErrorResponse.

If the user requests /api/todos/1, and 1 has been deleted, then we will want to respond with a not found error.

Create a new file called: “notFoundErrorResponse.ts”. Give it the following code:

import { StatusCode } from "status-code-enum"
import { restErrorResponseBuilder } from "./restErrorResponseBuilder";

export const notFoundErrorResponse = restErrorResponseBuilder("Not Found", StatusCode.ClientErrorNotFound)
And lastly when we don’t know what went wrong, let’s do our internal server error response.

Create a new file called: “internalServerErrorResponse.ts”. Give it the following code:

import { StatusCode } from "status-code-enum"
import { restErrorResponseBuilder } from "./restErrorResponseBuilder";

export const internalServerErrorResponse = restErrorResponseBuilder("Internal Server Error", StatusCode.ServerErrorInternal);
Ok now that we have covered some of the most common error responses. Let’s start to focus on the happy path.

When an object is created in the database we want to respond with a created response.

Create a new file called: “createdResponse.ts”. Give it the following code:

import { StatusCode } from "status-code-enum"
import { NextResponse } from "next/server"
import { RestApplicationResponseModel } from "../restResponseModels/restApplicationResponseModel"

export function createdResponse<T>(data: T): NextResponse {
    const restResponse: RestApplicationResponseModel<T> = {
        data: data
    }

    return NextResponse.json(restResponse, { status: StatusCode.SuccessCreated })
}
When an object is deleted from the database we want to respond with a no content response.

Create a new file called: “noContentResponse.ts”. Give it the following code:

import { StatusCode } from "status-code-enum"
import { NextResponse } from "next/server"

export function noContentResponse(): NextResponse {
    const nextResponse = new NextResponse(null, { status: StatusCode.SuccessNoContent });    

    return nextResponse;
}
Notice how this was a little different. We had to give a “null” as the first parameter to the constructor of the NextResponse class, and then the status code of no content instead of using the json method on the NextResponse class.

Finally, when the user requests a list of objects from the database, the details of one object, or an update request for an object succeeds we want to give them the response we hope to give them which is the ok response.

Create a new file called: “okResponse.ts”. Give it the following code:

import { StatusCode } from "status-code-enum"
import { NextResponse } from "next/server"
import { RestApplicationResponseModel } from "../restResponseModels/restApplicationResponseModel"

export function okResponse<T>(data: T): NextResponse {
    const restResponse: RestApplicationResponseModel<T> = {
        data
    }

    return NextResponse.json(restResponse, { status: StatusCode.SuccessOK })
}

Configure the common request handler

Now that we have all of our responses ready. Let’s use them in a common way. Most REST Requests that are handled on the application layer will attempt to do some validation, and if not valid, respond with an invalid request response. If the request is deemed valid, then an attempt to do the operation requested is taken. If something goes wrong during that portion of the request handling it will respond with some sort of an internal server error. If everything goes right then it will respond with the appropriate data and status code.

Create a new file called: “restRequestHandlerBuilder.ts” and give it the following code

import { NextRequest, NextResponse } from "next/server";
import { badRequestErrorResponse } from "../restResponses/badRequestErrorResponse";
import { badRequestErrorResponseFromZodIssues } from "../restResponses/badRequestErrorResponseFromZodIssues";
import { internalServerErrorResponse } from "../restResponses/internalServerErrorResponse";
import { ZodIssue } from "zod";

interface RestRequestValidationResult<RequestBodyType> {
    success: boolean;
    validatedRequestBody?: RequestBodyType;
    issues?: ZodIssue[];
}

interface ValidatedRequestDetailsParams<ParamsType, RequestBodyType> {
    validatedRequestBody?: RequestBodyType;
    params?: ParamsType;
}

export interface RestRequestHandlerBuilderOptions<ParamsType, RequestBodyType> {
    onValidateParams?: (params: ParamsType) => { isValid: boolean, errorMessage?: string };
    onValidateRequestAsync?: (req: NextRequest) => Promise<RestRequestValidationResult<RequestBodyType>>;
    onValidRequestAsync: (req: NextRequest, details?: ValidatedRequestDetailsParams<ParamsType, RequestBodyType>) => Promise<NextResponse>;    
}

export function restRequestHandlerBuilder<ParamsType, RequestBodyType>(options: RestRequestHandlerBuilderOptions<ParamsType, RequestBodyType>) {
    return async (req: NextRequest, params:ParamsType): Promise<NextResponse> => {
        try {
            let isValidRequest: boolean = false;
            let details: { validatedRequestBody?: RequestBodyType, params?: ParamsType} = {};

            if (options.onValidateParams) {
                const { isValid, errorMessage } = options.onValidateParams(params);
                if (!isValid) {
                    if (errorMessage) {
                        return badRequestErrorResponse(errorMessage);
                    }

                    return badRequestErrorResponse("invalid params");
                }

                details.params = params;
            }            

            if(options.onValidateRequestAsync) {
                const validation = await options.onValidateRequestAsync(req);
                if (!validation.success) {
                    const { issues } = validation;
    
                    return badRequestErrorResponseFromZodIssues(issues);
                } else {
                    details.validatedRequestBody = validation.validatedRequestBody;
                    isValidRequest = true;
                }                
            } else {
                isValidRequest = true;
            }

            if(isValidRequest) {
                const response = await options.onValidRequestAsync(req, details);                
                return response;
            } else {
                return badRequestErrorResponse();
            }

        } catch (error) {            
            if(error instanceof Error) {                
                return internalServerErrorResponse(error.message);
            }

            return internalServerErrorResponse();
        }
    }
}
A lot going on here. First of all remember that “T” from earlier? Well it doesn’t have to just be the letter “T” to be a type parameter. You can call that type parameter whatever you want. You can even specify multiple parameter types by separating them with comma’s within the angled brackets.

This is probably best explained with starting from the third interface. The “RestRequestHandlerBuilderOptions” which takes two type parameters for the ParamsType and RequestBodyType. That interface then requires the developer to define three functions. An onValidateParams, onValidateRequestAsync, and onValidaRequestAsync. The “onValidateParams” and “onValidateRequestAsync” are optional, as they will likely not be used when requesting lists of data. The onValidRequestAsync method is required as it will be used to issue the response. Each function requires a return value specified by the other two interfaces in the code, and those type parameters that were used in the initial RestRequestHandlerBuilderOptions are passed along to those other two interfaces. This ensures type safety and that the developer using this builder will be forced to return the correct type of data or else a compiler warning will happen.

The “restRequestHandlerBuilder” function has two type parameters. One for ParamsType and another for the RequestBodyType. Bear in mind that even “unknown” or “any” are technically valid types that a developer can use for this function. Let’s step through the function that this function returns. It returns a Promise which the NextJS Application router will need to send its response.

It has a try… catch setup and then validates the request with the onValidateParams and onValidateRequestAsync methods that were provided from the builder options. If the request is not valid then this function responds with the appropriate badRequestErrorResponse that we created earlier. If the request is valid then it gets the response data from the onValidaRequestAsync function which provides the validated details to the developer. If anything goes wrong with this request then the error is caught and an internalServerErrorResponse is done.

Configure the “todo” features

The features that we will be configuring will be selectors from the prisma client (I.E. The SELECT portion of the SQL statement). The return types of those selectors, REST Responses based on those return types, and request handlers to use those REST Responses.

Configure the Prisma selectors

To the “features” directory create a new directory called: “todos”. To the “todos” directory create a new directory called: “prismaSelectors”. To the “prismaSelectors” directory create a new file called: “todoSelector.ts”. Give it the following code:

import { Prisma } from '@prisma/client';

export const todoSelector = {
    id: true,
    title: true,
    dueDate: true,
    done: true,
} satisfies Prisma.TodoSelect;

Configure the Prisma Selector return type, I.E. Data types

This file defines which fields from the Todo table we will be selecting with prisma. That TodoSelect type was generated internally by Prisma because of our Schema file.

Create a new directory called: “prismaPayloads”. To the “prismaPayloads” directory create a new file called: “todoPayload.ts”. Give it the following code:

import { Prisma } from '@prisma/client';
import { todoSelector } from "../prismaSelectors/todoSelector";

export type TodoPayload = Prisma.TodoGetPayload<{ select: typeof todoSelector }>;
This will be the data type “T” for our okRestResponse. Basically, this creates a Type for us that will have the selected properties that we are requesting from our selector. You could imagine that this code is pretty much the same as this code (this is example code and does not belong anywhere in the solution).
export type TodoPayload = {
    id: number;
    title: string;
    dueDate: Date;
    done: boolean;
};
To illustrate further. Let’s say the selector was as follows (removing the dueDate key, again this code does not belong in the solution):
import { Prisma } from '@prisma/client';

export const todoSelector = {
    id: true,
    title: true,
    done: true,
} satisfies Prisma.TodoSelect;
Then the “export Type TodoPayload = Prisma.TodoGetPayload<{select: typeof todoSelector}>;” would return a type defined with this code (with the dueDate key removed, again, this code does not belong in the solution):
export type TodoPayload = {
    id: number;
    title: string;    
    done: boolean;
};

Configuring the REST Response Models

Create a new directory called: “restResponseModels”. To the “restResponseModels” directory create a new file called: “todoDetailsResponseModel.ts”. Give it the following code:

import { TodoPayload } from "../prismaPayloads/todoPayload";
import { RestApplicationResponseModel } from "../../common/restResponseModels/restApplicationResponseModel";

export type TodoDetailsResponseModel = RestApplicationResponseModel<TodoPayload>;
In the “restResponseModels” directory create a new file called: “todosListResponseModel.ts”. Give it the following code:
import { TodoPayload } from "../prismaPayloads/todoPayload";
import { RestApplicationResponseModel } from "../../common/restResponseModels/restApplicationResponseModel";

export type TodosListResponseModel = RestApplicationResponseModel<TodoPayload[]>;
Essentially what we are doing in both file is grabbing the TodoPayload from this feature, and the RestApplicationResponseModel from the common feature, and then exporting a type based on the RestApplicationResponseModel and providing a data type “T” of either the TodoPayload or an array of the TodoPayload. You could now imagine that the todoDetailsResponseModel now looks like this type (this code does not belong anywhere in the solution):
export type TodoDetailsResponseModel = {
    data?: {
        id: string;
        title: string;
        dueDate: Date;
        done: boolean;        
    },
    error?: {
        message: string;
    }
}

Configuring the REST Responses

Now that we have our response models, it’s time to generate our responses based on these models.

Within the “todos” directory create a new directory called: “restResponses”. To the “restResponses” directory create a new file called: “todoDetailsResponse.ts”. Give it the following code:

import { NextResponse } from "next/server";
import { okResponse } from "../../common/restResponses/okResponse";
import { TodoPayload } from "../prismaPayloads/todoPayload";

export const todoDetailsResponse = (todo: TodoPayload): NextResponse => {
    return okResponse(todo);
}
In the “restResponses” directory create a new file called: “listOfTodosResponse.ts”. Give it the following code:
import { NextResponse } from "next/server";
import { okResponse } from "../../common/restResponses/okResponse";
import { TodoPayload } from "../prismaPayloads/todoPayload";

export const todosListResponse = (todos: TodoPayload[]): NextResponse => {
    return okResponse(todos);
}

Configure the REST Request Handlers

Now it’s time to put these responses to use.

Create a new directory within the “todos” directory called: “restRequestHandlers”. To the “restRequestHandlers” directory create a new file called: ” getListOfTodosRequestHandler.ts”. Give it the following code:

import { NextRequest } from "next/server";
import { prismaClient } from "@/features/common/prisma/prismaClient";
import { todosListResponse } from "../restResponses/listOfTodosResponse";
import { todoSelector } from "../prismaSelectors/todoSelector";
import { restRequestHandlerBuilder, RestRequestHandlerBuilderOptions } from "@/features/common/restRequestHandlers/restRequestHandlerBuilder";

const getListOfTodosRequestHandlerBuilderOptions: RestRequestHandlerBuilderOptions<undefined, undefined> = {
    onValidRequestAsync: async (req: NextRequest) => {
        const todos = await prismaClient.todo.findMany({ select: todoSelector });

        return todosListResponse(todos);
    }
}

export const getListOfTodosRequestHandler = restRequestHandlerBuilder(getListOfTodosRequestHandlerBuilderOptions);
Let’s break this down a little bit. We have imported the NextRequest class from the “next/server” package. We also included our prismaClient, our TodosListResponse, todoSelector, the restRequestHandlerBuilder, and RestRequestHandlerBuilderOptions from without our project.

We then created an object based on the RestRequestHandlerBuilderOptions which we provided two “undefined” types. Remember these types are for the request parameters that we are expecting, and the request body that we are expecting. For this list of todos which will be “/api/todos”, there are no parameters to validate, nor is there a request body to validate, which is why we used the “undefined” type. The only method that we defined was the onValidRequestAsync, in which we use our prismaClient to findMany todos, and select the fields we defined in the todoSelector earlier. We then respond with the todosListResponse with the todos we found in the database. We then provide the requestBuilderOptions to the requestBuilder and return the results of that function (that creates a function) that will later be used with the nextjs application router.

Now let’s create a handler to get one todo, aka /api/todos/1.

Create a new file within the “restRequestHandlers” directory called: ” getTodoDetailsRequestHandler.ts”. Give it the following code:

import { NextRequest } from "next/server";
import { prismaClient } from "@/features/common/prisma/prismaClient";
import { todoSelector } from "../prismaSelectors/todoSelector";
import { todoDetailsResponse } from "../restResponses/todoDetailsResponse";
import { notFoundErrorResponse } from "../../common/restResponses/notFoundErrorResponse";
import { restRequestHandlerBuilder, RestRequestHandlerBuilderOptions } from "@/features/common/restRequestHandlers/restRequestHandlerBuilder";
import { IDParams } from "@/features/common/params/idParams";
import { idParamaterValidator } from "@/features/common/paramValidators/idParamaterValidator";

const getTodoDetailsRequestHandlerBuilderOptions: RestRequestHandlerBuilderOptions<IDParams, undefined> = {    
    onValidateParams: idParamaterValidator,

    onValidRequestAsync: async (req: NextRequest, details) => {
        if (details && details.params) {
            const { params } = details.params;
            const id = Number(params.id);
            const todo = await prismaClient.todo.findUnique({ where: { id: id }, select: todoSelector });

            if (todo) {
                return todoDetailsResponse(todo);
            } else {
                return notFoundErrorResponse();
            }
        } else {
            throw new Error("Params were not defined");
        }
    },
};

export const getTodoDetailsRequestHandler = restRequestHandlerBuilder(getTodoDetailsRequestHandlerBuilderOptions);
We pretty much had the same imports as last time. Only this time we included a few more. The first one is the IDParams type from the common feature, as well as the idParamaterValidator from the common feature. This time to the builder we provide the IDParams as the first type as those are the ParamaterTypes for the builder. To the onValidateParams method of the builder we just use the idParamaterValidator which means that if someone tries /api/todos/one they will receive an error stating that “one” is not a number.

With the onValidRequestAsync the request, and the validatedDetails are provided. The params key of the details object is optional so you still need to check for it here in this function. That portion of the “throw new Error…” is potentially unreachable code because of how the restRequestHandler handles it being null or undefined. However the compiler won’t let us get away with it, or we have to continually use our properties as “details?.params”, or “details?.validatedRequestBody”. But I prefer to do the truthy check instead of using those question marks.

To the “restRequestHandlers” directory create a new file called: “createTodoRequestHandler.ts”. Give it the following code:

import { Prisma } from "@prisma/client";
import { prismaClient } from "@/features/common/prisma/prismaClient";
import { NextRequest } from "next/server";
import { todoSelector } from "../prismaSelectors/todoSelector";
import { todoDetailsResponse } from "../restResponses/todoDetailsResponse";
import { TodoCreateInputObjectSchema } from "../../../prisma/generated/schemas/objects/TodoCreateInput.schema";
import { restRequestHandlerBuilder, RestRequestHandlerBuilderOptions } from "@/features/common/restRequestHandlers/restRequestHandlerBuilder";

const createTodoRequestHandlerBuilderOptions: RestRequestHandlerBuilderOptions<undefined, Prisma.TodoCreateInput> = {
    onValidateRequestAsync: async (req: NextRequest) => {
        const requestBody = await req.json();
        const validation = TodoCreateInputObjectSchema.safeParse(requestBody);

        if (!validation.success) {
            const { errors } = validation.error;
            return { success: false, issues: errors };
        } else {            
            return { success: true, validatedRequestBody: validation.data };
        }
    },

    onValidRequestAsync: async (req: NextRequest, details) => {                
        if(details && details.validatedRequestBody) {
            const createArgs: Prisma.TodoCreateArgs = {
                data: details.validatedRequestBody,
                select: todoSelector
            };
    
            const todo = await prismaClient.todo.create(createArgs);
    
            return todoDetailsResponse(todo);
        } else {
            throw new Error("Validated request body is undefined");
        }
    },
};

export const createTodoRequestHandler = restRequestHandlerBuilder(createTodoRequestHandlerBuilderOptions);
In this method we finally use the Zod validation schemas that were created for us earlier. Because we cannot control exactly what data is going to be provided in the request body, we do certainly want to validate it before it goes to the prismaClient. This requestHandlerBuilderOptions and the onValidateRequestAsync method we read the requestBody as json. We then provide those results to the TodoCreateInputObjectSchema from the generated schema objects that the zod prisma generator made for us. There is a lot going on there. But essentially what it is doing is making sure that the requestBody json matches the Prisma.TodoCreateInput type. It also does not allow for over posting of fields too.

When you holder your mouse over TodoCreateInput you will see that the type is defined like this

Therefore, if someone were to post this request body, it would be invalid because of the extra property.
{
    "title": "My Title",
    "dueDate": "2023-05-27",
    "done": false,
    "foo": "bar"
}
Furthermore, a payload like this would fail because of a missing property.
{
    "title": "My Title"    
}
This is also the same logic that we will be using for the update request except we will be using the Prisma.TodoUpdateInput type instead of the Prisma.CreateInput type.

Create a new file called: “updateTodoRequestHandler.ts”. Give it the following code:

import { Prisma } from "@prisma/client";
import { prismaClient } from "@/features/common/prisma/prismaClient";
import { NextRequest } from "next/server";
import { todoSelector } from "../prismaSelectors/todoSelector";
import { todoDetailsResponse } from "../restResponses/todoDetailsResponse";
import { IDParams } from "@/features/common/params/idParams";
import { idParamaterValidator } from "@/features/common/paramValidators/idParamaterValidator";
import { TodoUpdateInputObjectSchema } from "@/prisma/generated/schemas/objects/TodoUpdateInput.schema";
import { restRequestHandlerBuilder, RestRequestHandlerBuilderOptions } from "@/features/common/restRequestHandlers/restRequestHandlerBuilder";

const updateTodoRequestHandlerBuilderOptions: RestRequestHandlerBuilderOptions<IDParams, Prisma.TodoUpdateInput> = {
    onValidateParams: idParamaterValidator,

    onValidateRequestAsync: async (req: NextRequest) => {
        const requestBody = await req.json();
        const validation = TodoUpdateInputObjectSchema.safeParse(requestBody);

        if (!validation.success) {
            const { errors } = validation.error;
            return { success: false, issues: errors };
        } else {
            return { success: true, validatedRequestBody: validation.data };
        }
    },

    onValidRequestAsync: async (req: NextRequest, details) => {
        if (details && details.params && details.validatedRequestBody) {
            const id = Number(details.params.params.id);

            const updateArgs: Prisma.TodoUpdateArgs = {
                where: { id: id },
                data: details.validatedRequestBody,
                select: todoSelector
            };

            const todo = await prismaClient.todo.update(updateArgs);

            return todoDetailsResponse(todo);
        } else {
            throw new Error("Validated request body is undefined, or params are undefined.");
        }
    }
}

export const updateTodoRequestHandler = restRequestHandlerBuilder(updateTodoRequestHandlerBuilderOptions);
This is by far the most complex one since we do define all three methods in our RestRequestHandlerBuilderOptions object. We also define both type parameters. However if you think about the concepts we used to build the getTodoDetailsRequestHandler, and the createTodoRequestHandler it is basically concepts from both files in to one. Here again we will bail out if someone requested /api/todos/one. Also, if the person did not include necessary properties or added additional properties to their request body we will tell them that the request is not valid, and why. If we believe everything is valid then we attempt to perform the operation on the database. If the operation is not successful the internal server error will be given, otherwise if everything works as expected then we will return the data that we would like to have our application respond with.

Finally, we want to be able to delete tasks.

Create a new file called: “deleteTodoRequestHandler.ts”. Give it the following code:

import { NextRequest } from "next/server";
import { prismaClient } from "@/features/common/prisma/prismaClient";
import { noContentResponse } from "../../common/restResponses/noContentResponse";
import { restRequestHandlerBuilder, RestRequestHandlerBuilderOptions } from "@/features/common/restRequestHandlers/restRequestHandlerBuilder";
import { IDParams } from "@/features/common/params/idParams";
import { idParamaterValidator } from "@/features/common/paramValidators/idParamaterValidator";

const deleteTodoRequestHandlerBuilderOptions: RestRequestHandlerBuilderOptions<IDParams, undefined> = {
    onValidateParams: idParamaterValidator,

    onValidRequestAsync: async (req: NextRequest, details) => {
        if (details && details.params) {
            const { params } = details.params;
            const id = Number(params.id);
            await prismaClient.todo.delete({ where: { id: id } });

            return noContentResponse();
        } else {
            throw new Error("Params were not defined");
        }
    }
};

export const deleteTodoRequestHandler = restRequestHandlerBuilder(deleteTodoRequestHandlerBuilderOptions);

Configure the API Routes

Now that we have our todo request handlers defined. Let’s put them in a place that NextJS will actually read and use them.

In the “next-app” directory, and the “app” directory inside of that create a new directory called: “api”. In the “api” directory create a new directory called “todos”. In the “todos” directory create a new file called route.ts. Give it the following code:

import { getListOfTodosRequestHandler } from '@/features/todos/restRequestHandlers/getListOfTodosRequestHandler'
import { createTodoRequestHandler } from '@/features/todos/restRequestHandlers/createTodoRequestHandler'

export {
    getListOfTodosRequestHandler as GET,
    createTodoRequestHandler as POST
}
This means that getListOfTodosRequestHandler will respond to a GET request for /api/todos, and that createTodoRequestHandler will response to a POST request for /api/todos.

In the “todos” directory create a new directory called: “[id]”. To this “[id]” directory create a new file called: “route.js”. Give it the following code:

import { getTodoDetailsRequestHandler } from "@/features/todos/restRequestHandlers/getTodoDetailsRequestHandler"
import { updateTodoRequestHandler } from "@/features/todos/restRequestHandlers/updateTodoRequestHandler"
import { deleteTodoRequestHandler } from "@/features/todos/restRequestHandlers/deleteTodoRequestHandler"

export {
    getTodoDetailsRequestHandler as GET,
    updateTodoRequestHandler as PUT,
    deleteTodoRequestHandler as DELETE
}
This means that getTodoDetailsRequestHandler will respond to a GET request to /api/todos/[id], updatteTodoRequestHandler will respond to a PUT request to /api/todos/[id], and deleteTodoRequestHandler will respond to a DELETE request to /api/todos/[id].

The directory layout should look something like this:

Test the API Routes

You are now free to test the API Routes using Postman or any other REST Tester that you like. By running “npm run dev” within the next-app directory of the project. I will provide a few screen shots and descriptions of the tests that I did.

Bad request when trying to send extra properties that are not apart of the Todo model:

Bad request with title as the wrong type of data.
Success with the done property defined.
Success without the done property defined.
Success when requesting a list of todos.
Bad request when trying to get /api/todos/notanumber, like /api/todos/one
Not found when trying to get /api/todos/idnotindatabase, like /api/todos/3
Success when providing a known id to /api/todos/[id]
Bad request when providing an extra key to the request body on update
Bad request when providing the wrong data type to title
Success with valid request body on PUT to valid id
Bad request when sending not a number to the /api/todos/[id] route for PUT
Bad request when sending not a number to the /api/todos/[id] for DELETE
Success when providing a valid number to /api/todos/[id] for DELETE

Conclusion

NextJS has several great ways to create a REST API. IN this demonstration we integrated Zod and Prisma for Request Validation. Furthermore, Prisma is being used to communicate between our application and the database. By using builders, we can keep our code DRY (Don’t Repeat Yourself). We will also be able to use the models that we created earlier in our client application which we will demonstrate in the next part of this series.

Parts:

Part 1 – Create a Development Container, Create the Next App and Install Required Dependencies
Part 2 – Configure the REST Server Features
Part 3 – Configure the REST Client Features

About Intertech

Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.