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

What are we going to build
We will be build a simple todo list using the following:
  • 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
    We need to store our database URI in safe place. Create a file called .env.local (Next already knows to look for this file for environmental variables). Add the following with your desired database URI:
    DATABASE_URL=mongodb://localhost:27017/next_todo_list
    API_URL=http://localhost:3000/api/todos
    We also need to install the mongoose library so we can connect to mongo.
    npm install mongoose
    So we don't have to write the connection code over and over again, let's write it in one file and export the connection so it can be used in our different API routes.
  • 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
    Now that we have our connection we can build our api. Creating an API in next involves creating files in the /pages/api folder.
    We need to create logic for two urls (method handling is done within the route):
  • /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)
  • We will be taking advantage of dynamic object keys to create different possibilities depending on the request method, although to keep typescript happy we will need to create an interface for the object we will create.
    Create a file /utils/types.ts
    // 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/
    We need to define two possibilities here based on the method:
  • 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
    Test the routes by making GET and POST requests to /api/todos (make sure to include a json body in the post request)
    NOTE if you get an error that looks like OverwriteModelError: Cannot overwrite 'Todo' model once compiled. in your terminal it's cause in development mode it's doing hot replacement which means the Todo model persists but it tries to create it everytime the dev server resets so it gets mad your trying to make a duplicate. This shouldn't be the case in production (when we run npm run build then run it with npm run start).
    Now to create the remaining routes in /pages/api/todos/[id].ts
  • 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
    Test all the endpoints, make sure to leave some sample todos
    With this our API should be pretty much done!!! Now to build the frontend piece!
    Step 3 - Create the Index Page (list all todos)
    So our main page would be /pages/index.tsx, we will be using server side rendering since the todos may change frequently (if that wasn't the case we'd prefer static rendering). In order to trigger server side rendering we will export a function called getServerSideProps which will return an object with any data we plan to render from the server.
    index.tsx
    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
    Head over to localhost:3000 and see our project at work!
    Step 4 - The Show Page
    So you may notice the links for the individual todos link to a page we haven't created /todos/:id to create this page we need to create a file called /pages/todos/[id].tsx
    Using the param we can fetch the individual todo inside getServerSideProps for this particular page.
    /pages/todos/[id].tsx
    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
    From this show page we can now:
  • 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.
  • The only thing left, the ability to create todos!
    Step 5 - The Create Page
    In /pages/index.tsx let's add button to take us to a "Create Todo" 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>
    Now create /pages/todos/create.txt
    This page will always be the same since it's just a form, so we will NOT export a getServerSideProps for it, which means Next will statically render (pre-render) this page since that is faster when no server side rendering is needed. If we did ever need to get props for a page we still want statically rendered there are some other functions that can be exported:
  • 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.

  • /pages/todos/create.tsx
    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...
    So we built the application and in a moment we will deploy it to Vercel (the creators of NextJS). During this build we have used...
  • 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.
  • To run the final build locally
  • npm run build builds out the application rendering all static pages
  • npm run start will start the application that npm run build created
  • Deployment
    This is the awesome part, usually deploying a website with backend features require at minimum some setup even on Heroku. With NextJS, since it is created by Vercel works seamlessly by just connecting your github repo to vercel and your deployed. Pretty Amazing!
    Learn More HERE
    If you enjoyed this tuturial find more of my work at http://resources.alexmercedcoder.com

    18

    This website collects cookies to deliver better user experience

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