Part 3 – 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 Client Features
Now we are confident in our REST Server. Let’s now focus on getting our client to connect to the server. Since NextJS is a fullstack framework we will be adding the client files to the same project.
Install Redux Toolkit (RTK)
One of the most popular state management tools for react is the Redux Toolkit (RTK). We will be creating a store for our Todos application and utilizing their createApi and enhanceEndpoints features to connect to the server responses. If you are following along from previously, be sure to do a Ctrl+C to stop the “npm run dev” that you might have done earlier.
Execute the following command to install these tools:
npm install @reduxjs/toolkit react-redux --save
import { configureStore, ConfigureStoreOptions } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
export const createStore = (
options?: ConfigureStoreOptions['preloadedState'] | undefined
) =>
configureStore({
reducer: {
},
})
export const store = createStore()
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export type RootState = ReturnType<typeof store.getState>
export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector
Create the base API
First, we will create a base API to use with our application. After the base API is created, we will then add additional endpoints to the api, and make them part of the store.
In the “next-app/features/common/store” folder add a new file called: “api.ts” and give it the following code:
import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react'
// Create our baseQuery instance
const baseQuery = fetchBaseQuery({
baseUrl: '/api/',
})
const baseQueryWithRetry = retry(baseQuery, { maxRetries: 6 })
/**
* Create a base API to inject endpoints into elsewhere.
* Components using this API should import from the injected site,
* in order to get the appropriate types,
* and to ensure that the file injecting the endpoints is loaded
*/
export const api = createApi({
/**
* `reducerPath` is optional and will not be required by most users.
* This is useful if you have multiple API definitions,
* e.g. where each has a different domain, with no interaction between endpoints.
* Otherwise, a single API definition should be used in order to support tag invalidation,
* among other features
*/
reducerPath: 'applicationApi',
/**
* A bare bones base query would just be `baseQuery: fetchBaseQuery({ baseUrl: '/' })`
*/
baseQuery: baseQueryWithRetry,
/**
* Tag types must be defined in the original API definition
* for any tags that would be provided by injected endpoints
*/
tagTypes: ['Todos'],
/**
* This api has endpoints injected in adjacent files,
* which is why no endpoints are shown below.
* If you want all endpoints defined in the same file, they could be included here instead
*/
endpoints: () => ({}),
})
In the “next-app/features/common/store” folder add a new file called: “provider.tsx” and give it the following code:
"use client";
import React from "react";
import { store } from "./index";
import { Provider } from "react-redux";
export function Providers({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
Create the TODOS API
In the “features/todos” folder create a new folder called: “store”. To the “store” folder create a new file called: “todos.ts”. Give it the following code:
import { Prisma } from '@prisma/client'
import { api } from '@/features/common/store/api'
import { TodoDetailsResponseModel } from '@/features/todos/restResponseModels/todoDetailsResponseModel'
import { TodosListResponseModel } from '@/features/todos/restResponseModels/todosListResponseModel'
type TodoUpdateInputWithId = Prisma.TodoUpdateInput & {
id: number
}
export const todosApi = api.injectEndpoints({
endpoints: (builder) => ({
getTodos: builder.query<TodosListResponseModel, void>({
query: () => 'todos',
}),
getTodo: builder.query<TodoDetailsResponseModel, number>({
query: (id) => `todos/${id}`,
}),
createTodo: builder.mutation<TodoDetailsResponseModel, Prisma.TodoCreateInput>({
query: (body) => ({
url: 'todos',
method: 'POST',
body
})
}),
updateTodo: builder.mutation<TodoDetailsResponseModel, TodoUpdateInputWithId>({
query: (data) => {
const { id } = data;
const dataToPut: Prisma.TodoUpdateInput = {
title: data.title,
dueDate: data.dueDate,
done: data.done
}
return {
url: `todos/${id}`,
method: 'PUT',
body: dataToPut
}
}
}),
deleteTodo: builder.mutation<undefined, Number>({
query: (id) => {
return {
url: `todos/${id}`,
method: 'DELETE'
}
}
})
}),
})
export const {
useGetTodosQuery,
useGetTodoQuery,
useCreateTodoMutation,
useUpdateTodoMutation,
useDeleteTodoMutation,
} = todosApi
export const {
endpoints: {
getTodos,
getTodo,
createTodo,
updateTodo,
deleteTodo
}
} = todosApi
Something interesting here is that I had to add an id parameter to the update request. The reason for that is that the query parameter only takes one type, and that type must have the id in it to specify the URL that we want to PUT. But I was also able to remove that ID before performing the PUT by manually specifying the fields of the request body.
For more information about creating queries and mutation see these links:
https://redux-toolkit.js.org/rtk-query/usage/queries
https://redux-toolkit.js.org/rtk-query/usage/mutations
Update the store to use the API Reducer
Return to “features/common/store/index.ts” Give it the following code:
import { configureStore, ConfigureStoreOptions, getDefaultMiddleware } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import { api } from './api';
export const createStore = (
options?: ConfigureStoreOptions['preloadedState'] | undefined
) =>
configureStore({
reducer: {
[api.reducerPath]: api.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
...options,
})
export const store = createStore()
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export type RootState = ReturnType<typeof store.getState>
export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector
Update the Global Layout to use the Global RTK Store
In the “app” folder there is a “layout.tsx” file. Update it to use this code:
import './globals.css'
import { Inter } from 'next/font/google'
import { Providers } from '@/features/common/store/provider'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}
Remove boilerplate styles
While we are at it. Let’s remove the customized css stuff from the boilerplate. Go in to global.css and make it such it only has this code:
@tailwind base;
@tailwind components;
@tailwind utilities;
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./features/**/*.{js,ts,jsx,tsx,mdx}'
],
plugins: [],
}
Create the TODOS list page
At this point we are ready to create the TODOS list page. Let’s begin by preparing the components that will be on the list page. The components that will be on the list page include a create button, the list itself, and the items on the list.
Create Button Component
To the “features/todos” folder create a new folder called: “components”. To the components folder create a new folder called: “listOfTodosPageComponents”. To the “listOfTodosPageComponents” folder create a new file called: “createNewTodoButton.tsx”
Give it the following code:
import React from 'react'
import Link from 'next/link'
function CreateNewTodoButton() {
return (
<Link href="/todos/create" className='block p-2 m-2 text-white bg-green-500 rounded'>Create New Todo</Link>
)
}
export default CreateNewTodoButton
TODO List Item Component
To the “features/todos/components/listOfTodosPageComponents” folder create a new file called: “todoListItem.tsx”. Give it the following code:
import React from 'react'
import Link from 'next/link'
import { TodoPayload } from '@/features/todos/prismaPayloads/todoPayload'
interface TodoListItemProps {
todo: TodoPayload,
deleteHandler: (id: number) => void
}
function TodoListItem({todo, deleteHandler}: TodoListItemProps) {
return (
<div className='flex-1 m-1 p-2 bg-teal-400'>
<div className='grid p-4 gap-2 grid-rows-2 bg-white rounded'>
<div>
<div className='text-xl font-bold'>{todo.title}</div>
<div>Due: {todo.dueDate.toString().split("T")[0]}</div>
<div>Done: {todo.done.valueOf().toString()}</div>
</div>
<div>
<div className='grid grid-cols-2 gap-2 bg-white'>
<Link href={`/todos/edit/${todo.id}`} className='block p-2 m-2 text-white bg-blue-500 rounded'>Edit</Link>
<button className='p-2 m-2 text-white bg-red-500 rounded' onClick={() => deleteHandler(todo.id)}>Delete</button>
</div>
</div>
</div>
</div>
)
}
export default TodoListItem
TODOS List Component
In the “features/todo/components/listOfTodosPageComponents” folder create a new file called: “listOfTods.tsx”. Give it the following code:
import React from 'react'
import TodoListItem from './todoListItem'
import { TodoPayload } from '@/features/todos/prismaPayloads/todoPayload'
interface ListOfTodosProps {
todos: TodoPayload[],
deleteHandler: (id: number) => void
}
function ListOfTodos(props: ListOfTodosProps) {
return (
<div className='flex flex-col p-6 bg-gray-400'>
{props.todos.map((todo) => (
<TodoListItem key={todo.id} todo={todo} deleteHandler={props.deleteHandler} />
))}
</div>
)
}
export default ListOfTodos
TODOS List Page Component
To the “features/todo” folder create a new folder called: “pages”. In the “pages” folder create a new file called: “listOfTodosPage.tsx” and give it the following code:
import React from 'react'
import { useGetTodosQuery, useDeleteTodoMutation } from '@/features/todos/store/todos'
import CreateNewTodoButton from '@/features/todos/components/listOfTodosPageComponents/createNewTodoButton'
import ListOfTodosComponent from '@/features/todos/components/listOfTodosPageComponents/listOfTodos'
function ListOfTodosPage() {
const { isFetching, isError, error, isSuccess, data, refetch } = useGetTodosQuery(undefined, {
refetchOnMountOrArgChange: true
});
const [deleteTodo, {isSuccess: deleteSuccess}] = useDeleteTodoMutation();
const deleteHandler = (id: number) => {
const confirmed = confirm("Are you sure you want to delete this todo?");
if (confirmed) {
deleteTodo(id);
}
}
React.useEffect(() => {
if (deleteSuccess) {
refetch();
}
}, [deleteSuccess, refetch])
return (
<div>
<h1 className="text-3xl">List of Todos</h1>
{isFetching && <div>Loading...</div>}
{isError && <div>{error.toString()}</div>}
{isSuccess && data && data.data && (
<>
<CreateNewTodoButton />
<ListOfTodosComponent todos={data.data} deleteHandler={deleteHandler} />
</>
)}
</div>
)
}
export default ListOfTodosPage
Add the TODOS List Page Component to the Route
To the “app” folder create a new folder called; “todos”. To the “todos” folder create a new file called: “page.tsx”. Give it the following code:
"use client";
import ListOfTodosPage from '@/features/todos/pages/listOfTodosPage'
export default ListOfTodosPage
Update the “app/page.tsx” file with the following code:
import Link from "next/link"
export default function Home() {
return (
<main className="p-2">
<h1 className="text-3xl">Welcome</h1>
<p>
<Link className="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="/todos">Manage Todos</Link>
</p>
</main>
)
}
Test the TODOS List Page
At this point you should be able to start up your application (either npm run dev, or a combination of npm run build with npm run start). And then browse to the TODOS List Page. Depending on what kind of testing you ended up with during the POSTMAN testing that was done earlier. You may see a screen similar to this:
Create the Create TODO Page
Let’s begin by creating some components that will be common for the create and edit forms.
Create the ID Form Control
To the “features/todos/components” folder create a new folder called: “createOrEditFormControls”. Create a new file called: “idFormControl.tsx”. Give it the following code:
import React from 'react'
interface IdFormControlProps {
defaultValue: number
}
function IdFormControl({ defaultValue }: IdFormControlProps) {
return (
<input type="hidden" name="id" value={defaultValue} />
)
}
export default IdFormControl
Create the Title Form Control
To the “features/todos/components/createOrEditFormControls” folder create a new file called: “titleFormControl.tsx”. Give it the following code:
import React from 'react'
interface TitleFormControlProps {
defaultValue: string
}
function TitleFormControl({ defaultValue }: TitleFormControlProps) {
const [title, setTitle] = React.useState(defaultValue);
return (
<>
<label
className="p-2 m-2 text-white bg-green-500 rounded"
htmlFor="title">
Title
</label>
<input
className="p-2 m-2 text-black border border-gray-500 rounded"
type="text"
name="title"
id="title"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
</>
)
}
export default TitleFormControl
Create the Due Date Form Control
To the “features/todos/components/createOrEditFormControls” folder create a new file called: “dueDateFormControl.tsx”. Give it the following code:
import React from 'react'
interface DueDateFormControlProps {
defaultValue: string
}
function DueDateFormControl({ defaultValue }: DueDateFormControlProps) {
const [dueDate, setDueDate] = React.useState(defaultValue);
return (
<>
<label
className="p-2 m-2 text-white bg-green-500 rounded"
htmlFor="dueDate">
Due Date
</label>
<input
className="p-2 m-2 text-black border border-gray-500 rounded"
type="date"
name="dueDate"
id="dueDate"
value={dueDate}
onChange={(e) => setDueDate(e.currentTarget.value)}
/>
</>
)
}
export default DueDateFormControl
Create the Done Form Control
To the “features/todos/components/createOrEditFormControls” folder create a new file called: “doneFormControl.tsx”. Give it the following code:
import React from 'react'
interface DoneFormControlProps {
defaultValue: boolean
}
function DoneFormControl({ defaultValue }: DoneFormControlProps) {
const [done, setDone] = React.useState(defaultValue);
return (
<>
<label
className="p-2 m-2 text-white bg-green-500 rounded"
htmlFor="done">
Done
</label>
<input
className="p-2 m-2 text-black border border-gray-500 rounded"
type="checkbox"
name="done"
id="done"
checked={done}
onChange={(e) => setDone(e.currentTarget.checked)}
/>
</>
)
}
export default DoneFormControl
Create the Submit Button Form Control
To the “features/todos/components/createOrEditFormControls” folder create a new file called: “submitButton.tsx”. Give it the following code:
import React from 'react'
function SubmitButton() {
return (
<input type="submit" value="Submit" className="p-2 m-2 text-white bg-green-500 rounded" />
)
}
export default SubmitButton
Create the Create Form Component
To the “features/todos/components” folder create a new file called; “createTodoForm.tsx”. Give it the following code:
import React from 'react'
import TitleFormControl from './createOrEditFormControls/titleFormControl'
import DueDateFormControl from './createOrEditFormControls/dueDateFormControl'
import DoneFormControl from './createOrEditFormControls/doneFormControl'
import SubmitButton from './createOrEditFormControls/submitButton'
interface CreateTodoFormProps {
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
}
function CreateTodoForm({ handleSubmit }: CreateTodoFormProps) {
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<TitleFormControl defaultValue="" />
<DueDateFormControl defaultValue="" />
<DoneFormControl defaultValue={false} />
<SubmitButton />
</form>
)
}
export default CreateTodoForm
Create the Create Form Page Component
To the “features/todos/pages” folder create a new file called: “createTodoPage.tsx”. Give it the following code:
"use client";
import React from 'react'
import CreateTodoForm from '@/features/todos/components/createTodoForm'
import { useCreateTodoMutation } from '@/features/todos/store/todos'
import { useRouter } from 'next/navigation';
function CreateTodoPage() {
const router = useRouter();
const [createTodo, { isError, isSuccess }] = useCreateTodoMutation();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const dueDate = formData.get('dueDate') as string;
const done = formData.get('done') as string;
createTodo({
title,
dueDate,
done: done === 'on'
})
}
React.useEffect(() => {
if (isError) {
alert('Error creating todo');
}
if (isSuccess) {
router.push("/todos");
}
}, [isError, isSuccess, router])
return (
<div>
<h1 className="text-3xl">Create Todo</h1>
<CreateTodoForm handleSubmit={handleSubmit} />
</div>
)
}
export default CreateTodoPage
Add the Create TODO Page to the Route
To the “app/todos” folder create a new folder called: “create”. Create a new file called: “page.tsx”. Give it the following code:
"use client";
import CreateTodoPage from "@/features/todos/pages/createTodoPage";
export default CreateTodoPage;
Create the Update TODO Page
With the components that we made earlier this should be a little simpler.
Create the Edit Form Component
To the “features/todos/components” folder create a new file called: “editTodoForm.tsx”. Give it the following code:
import React from 'react'
import IdFormControl from './createOrEditFormControls/idFormControl'
import TitleFormControl from './createOrEditFormControls/titleFormControl'
import DueDateFormControl from './createOrEditFormControls/dueDateFormControl'
import DoneFormControl from './createOrEditFormControls/doneFormControl'
import SubmitButton from './createOrEditFormControls/submitButton'
interface EditTodoFormProps {
defaultValues: {
id: number,
title: string,
dueDate: string,
done: boolean
},
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
}
function EditTodoForm({ defaultValues, handleSubmit }: EditTodoFormProps) {
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<IdFormControl defaultValue={defaultValues.id} />
<TitleFormControl defaultValue={defaultValues.title} />
<DueDateFormControl defaultValue={defaultValues.dueDate} />
<DoneFormControl defaultValue={defaultValues.done} />
<SubmitButton />
</form>
)
}
export default EditTodoForm
Create the Edit Form Page Component
To the “features/todos/pages” folder create a new file called: “updateTodoPage.tsx”. Give it the following code:
import React from 'react'
import EditTodoForm from '../components/editTodoForm';
import { useUpdateTodoMutation, useGetTodoQuery } from '@/features/todos/store/todos'
import { useRouter } from 'next/navigation';
import { IDParams } from '@/features/common/params/idParams';
import { idParamaterValidator } from '@/features/common/paramValidators/idParamaterValidator';
function UpdateTodoPage({ params }: IDParams) {
const validationResult = idParamaterValidator({ params });
if (!validationResult.isValid) {
throw new Error("Invalid id parameter");
}
const router = useRouter();
const [updateTodo, { isError, isSuccess }] = useUpdateTodoMutation();
const { data, isFetching } = useGetTodoQuery(Number(params.id), {
refetchOnMountOrArgChange: true
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const id = formData.get('id') as string;
const title = formData.get('title') as string;
const dueDate = formData.get('dueDate') as string;
const done = formData.get('done') as string;
updateTodo({
id: Number(id),
title,
dueDate,
done: done === 'on'
})
}
React.useEffect(() => {
if (isError) {
alert('Error updating todo');
}
if (isSuccess) {
router.push("/todos");
}
}, [isError, isSuccess, router])
return (
<div>
<h1 className="text-3xl">Update Todo</h1>
{
isFetching ? <p>Loading...</p> : (
data && data.data &&
<EditTodoForm
defaultValues={{
id: Number(params.id),
title: data.data.title,
dueDate: new Date(data.data.dueDate.toString()).toISOString().split('T')[0],
done: data.data.done
}}
handleSubmit={handleSubmit} />
)
}
</div>
)
}
export default UpdateTodoPage
Add the Update TODO Page to the Route
To the “app” folder create a new folder called: “edit”. To the “edit” folder create a new folder called: “[id]”. In that folder create a new file called: “page.tsx”. Give it the following code:
"use client";
import UpdateTodoPage from '@/features/todos/pages/updateTodoPage';
export default UpdateTodoPage
Test the entire Application
At this point you should be able to test out the entire application. All create, read, update, delete and list functionality.
Conclusion
NextJS is certainly a powerful platform to create full stack web applications. While it does provide functionality to create server side rendered pages, it also provides functionality to create static site, and single page applications. This demonstration was mostly in regards to a single page application. All routing and rendering were performed client side, while the fetching of data from the database was all performed via REST Calls. Combining Prisma and RTK Query you are able to use the same models for REST Responses, and do not need to manually rekey them in. If you follow this practice, when the database changes, the types that both the client and server recognize will change as well.
GitHub Repo: https://github.com/woodman231/nextjs-prisma-todos-rest
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.