Building a Full Stack Todo List with MongoDB, NextJS & Typescript

What are we going to build

  • NextJS: A Full Stack framework built around React offering Client Side, Server Side and Static Rendering.
  • Typescript: A Javascript superset made by microsoft to write scalable code
  • Mongo: A Document Database

Getting Started

  • generate a new typescript next project with the following command: npx create-next-app --ts
  • cd into the new project folder and run dev server npm run dev checkout that site is visible on localhost:3000

Step 1 — Connect to your mongo database

DATABASE_URL=mongodb://localhost:27017/next_todo_list
API_URL=http://localhost:3000/api/todos
  • create a folder called utils to write helper functions in the project root (the folder with the package.json) and in folder create a connection.ts
//IMPORT MONGOOSE
import mongoose, { Model } from "mongoose"
// CONNECTING TO MONGOOSE (Get Database Url from .env.local)
const { DATABASE_URL } = process.env
// connection function
export const connect = async () => {
const conn = await mongoose
.connect(DATABASE_URL as string)
.catch(err => console.log(err))
console.log("Mongoose Connection Established")
// OUR TODO SCHEMA
const TodoSchema = new mongoose.Schema({
item: String,
completed: Boolean,
})
// OUR TODO MODEL
const Todo = mongoose.models.Todo || mongoose.model("Todo", TodoSchema)
return { conn, Todo }
}

Step 2 — Create Our API

  • /todos/ this will be handled with this file... /pages/api/todos/index.ts (index.ts will always serve a route following the folder name)
  • /todos/:id this will be handled with this file... /pages/api/todos/[id].ts (the [] denote a URL param in next)
// Interface to defining our object of response functions
export interface ResponseFuncs {
GET?: Function
POST?: Function
PUT?: Function
DELETE?: Function
}
// Interface to define our Todo model on the frontend
export interface Todo {
_id?: number
item: string
completed: boolean
}

/todos/

  • If the request is a GET request it should return all the todos (index route)
  • If the request is a POST request it should create a new todo (create route)
import { NextApiRequest, NextApiResponse } from "next"
import { connect } from "../../../utils/connection"
import { ResponseFuncs } from "../../../utils/types"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
//capture request method, we type it as a key of ResponseFunc to reduce typing later
const method: keyof ResponseFuncs = req.method as keyof ResponseFuncs
//function for catch errors
const catcher = (error: Error) => res.status(400).json({ error })
// Potential Responses
const handleCase: ResponseFuncs = {
// RESPONSE FOR GET REQUESTS
GET: async (req: NextApiRequest, res: NextApiResponse) => {
const { Todo } = await connect() // connect to database
res.json(await Todo.find({}).catch(catcher))
},
// RESPONSE POST REQUESTS
POST: async (req: NextApiRequest, res: NextApiResponse) => {
const { Todo } = await connect() // connect to database
res.json(await Todo.create(req.body).catch(catcher))
},
}
// Check if there is a response for the particular method, if so invoke it, if not response with an error
const response = handleCase[method]
if (response) response(req, res)
else res.status(400).json({ error: "No Response for This Request" })
}
export default handler
  • GET REQUEST THAT DISPLAY ONE TODO (SHOW ROUTE)
  • PUT REQUEST TO UPDATE ONE TODO (UPDATE ROUTE)
  • DELETE REQUEST TO DELETE ONE TODO (DELETE ROUTE)
import { NextApiRequest, NextApiResponse } from "next"
import { connect } from "../../../utils/connection"
import { ResponseFuncs } from "../../../utils/types"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
//capture request method, we type it as a key of ResponseFunc to reduce typing later
const method: keyof ResponseFuncs = req.method as keyof ResponseFuncs
//function for catch errors
const catcher = (error: Error) => res.status(400).json({ error })
// GRAB ID FROM req.query (where next stores params)
const id: string = req.query.id as string
// Potential Responses for /todos/:id
const handleCase: ResponseFuncs = {
// RESPONSE FOR GET REQUESTS
GET: async (req: NextApiRequest, res: NextApiResponse) => {
const { Todo } = await connect() // connect to database
res.json(await Todo.findById(id).catch(catcher))
},
// RESPONSE PUT REQUESTS
PUT: async (req: NextApiRequest, res: NextApiResponse) => {
const { Todo } = await connect() // connect to database
res.json(
await Todo.findByIdAndUpdate(id, req.body, { new: true }).catch(catcher)
)
},
// RESPONSE FOR DELETE REQUESTS
DELETE: async (req: NextApiRequest, res: NextApiResponse) => {
const { Todo } = await connect() // connect to database
res.json(await Todo.findByIdAndRemove(id).catch(catcher))
},
}
// Check if there is a response for the particular method, if so invoke it, if not response with an error
const response = handleCase[method]
if (response) response(req, res)
else res.status(400).json({ error: "No Response for This Request" })
}
export default handler

Step 3 — Create the Index Page (list all todos)

import { Todo } from "../utils/types"
import Link from "next/link"
// Define the components props
interface IndexProps {
todos: Array<Todo>
}
// define the page component
function Index(props: IndexProps) {
const { todos } = props
return (
<div>
<h1>My Todo List</h1>
<h2>Click On Todo to see it individually</h2>
{/* MAPPING OVER THE TODOS */}
{todos.map(t => (
<div key={t._id}>
<Link href={`/todos/${t._id}`}>
<h3 style={{ cursor: "pointer" }}>
{t.item} - {t.completed ? "completed" : "incomplete"}
</h3>
</Link>
</div>
))}
</div>
)
}
// GET PROPS FOR SERVER SIDE RENDERING
export async function getServerSideProps() {
// get todo data from API
const res = await fetch(process.env.API_URL as string)
const todos = await res.json()
// return props
return {
props: { todos },
}
}
export default Index

Step 4 — The Show Page

import { Todo } from "../../utils/types"
import { useRouter } from "next/router"
import { useState } from "react"
// Define Prop Interface
interface ShowProps {
todo: Todo
url: string
}
// Define Component
function Show(props: ShowProps) {
// get the next router, so we can use router.push later
const router = useRouter()
// set the todo as state for modification
const [todo, setTodo] = useState<Todo>(props.todo)
// function to complete a todo
const handleComplete = async () => {
if (!todo.completed) {
// make copy of todo with completed set to true
const newTodo: Todo = { ...todo, completed: true }
// make api call to change completed in database
await fetch(props.url + "/" + todo._id, {
method: "put",
headers: {
"Content-Type": "application/json",
},
// send copy of todo with property
body: JSON.stringify(newTodo),
})
// once data is updated update state so ui matches without needed to refresh
setTodo(newTodo)
}
// if completed is already true this function won't do anything
}
// function for handling clicking the delete button
const handleDelete = async () => {
await fetch(props.url + "/" + todo._id, {
method: "delete",
})
//push user back to main page after deleting
router.push("/")
}
//return JSX
return (
<div>
<h1>{todo.item}</h1>
<h2>{todo.completed ? "completed" : "incomplete"}</h2>
<button onClick={handleComplete}>Complete</button>
<button onClick={handleDelete}>Delete</button>
<button
onClick={() => {
router.push("/")
}}
>
Go Back
</button>
</div>
)
}
// Define Server Side Props
export async function getServerSideProps(context: any) {
// fetch the todo, the param was received via context.query.id
const res = await fetch(process.env.API_URL + "/" + context.query.id)
const todo = await res.json()
//return the serverSideProps the todo and the url from out env variables for frontend api calls
return { props: { todo, url: process.env.API_URL } }
}
// export component
export default Show
  • mark the todo complete using our update route
  • delete the todo using our delete route
  • go back to the main page with the go back button.

Step 5 — The Create Page

<h1>My Todo List</h1>
<h2>Click On Todo to see it individually</h2>
<Link href="/todos/create"><button>Create a New Todo</button></Link>
  • getStaticPaths: allows you define an array of urls, typically used for dynamic routes like [id].tsx to define all the possible urls and then pre-generate each one.
  • getStaticProps: allows you to fetch data from other sources and pass it as props at build time before the page is pre-rendered. This request will not occur when the user accesses the page like getServerSideProps or plain frontend fetch requests.
import { useRouter } from "next/router"
import { FormEvent, FormEventHandler, useRef } from "react"
import { Todo } from "../../utils/types"
// Define props
interface CreateProps {
url: string
}
// Define Component
function Create(props: CreateProps) {
// get the next route
const router = useRouter()
// since there is just one input we will use a uncontrolled form
const item = useRef<HTMLInputElement>(null)
// Function to create new todo
const handleSubmit: FormEventHandler<HTMLFormElement> = async event => {
event.preventDefault()
// construct new todo, create variable, check it item.current is not null to pass type checks
let todo: Todo = { item: "", completed: false }
if (null !== item.current) {
todo = { item: item.current.value, completed: false }
}
// Make the API request
await fetch(props.url, {
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(todo),
})
// after api request, push back to main page
router.push("/")
}
return (
<div>
<h1>Create a New Todo</h1>
<form onSubmit={handleSubmit}>
<input type="text" ref={item}></input>
<input type="submit" value="create todo"></input>
</form>
</div>
)
}
// export getStaticProps to provie API_URL to component
export async function getStaticProps(context: any) {
return {
props: {
url: process.env.API_URL,
},
}
}
// export component
export default Create

So far…

  • Server Side Rendering: Any page that exported getServerSideProps will be rendered on the server for each request
  • State Rendering: if not props function is exported or getStaticProps is exported will be rendered once at build time, and that static file will be served for all request till another build occurs
  • Client Side Rendering: Anytime we use useState or useReducer in a component to trigger changes, those changes will happen in the client (in the browser while the user is using the site) like in traditional react.
  • npm run build builds out the application rendering all static pages
  • npm run start will start the application that npm run build created

Deployment

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Alex Merced Coder

Alex Merced is a Developer Advocate for Dremio and host of the Web Dev 101 and Datanation Podcasts.