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();
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;
}
}
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;
}
import { RestApplicationErrorResponseModel } from "./restApplicationErrorResponseModel";
export interface RestApplicationResponseModel<T> {
data?: T;
error?: RestApplicationErrorResponseModel;
}
interface SomeDataType {
someData: string;
}
let results: RestApplicationResponseModel<SomeDataType> = {};
let errorOccured = false;
if (errorOccured) {
results.error = {
message: "some error"
}
} else {
results.data = {
someData: "some data"
}
}
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 })
}
}
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)
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();
}
}
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)
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);
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 })
}
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;
}
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();
}
}
}
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
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 }>;
export type TodoPayload = {
id: number;
title: string;
dueDate: Date;
done: boolean;
};
import { Prisma } from '@prisma/client';
export const todoSelector = {
id: true,
title: true,
done: true,
} satisfies Prisma.TodoSelect;
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>;
import { TodoPayload } from "../prismaPayloads/todoPayload";
import { RestApplicationResponseModel } from "../../common/restResponseModels/restApplicationResponseModel";
export type TodosListResponseModel = RestApplicationResponseModel<TodoPayload[]>;
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);
}
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);
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);
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);
When you holder your mouse over TodoCreateInput you will see that the type is defined like this
{
"title": "My Title",
"dueDate": "2023-05-27",
"done": false,
"foo": "bar"
}
{
"title": "My Title"
}
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);
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
}
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
}
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:
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.