How to Create an API with Strapi by Rebuilding the MDN Express.js Local Library Website

This tutorial is a rebuild of the MDN "Local Library" Express (Nodejs) tutorial with modern web tools. We develop a web app that might be used to manage the catalog for a local library.

The main goal of this tutorial is to see how easy to create a backend with Strapi for your App in a few minutes from a user-friendly admin panel and let modern frontend tools like NextJS, NuxtJS, GatsbyJS handle the rendering of your content.

Our final goal is to have a working copy of the expressjs local library tutorial powered by StrapiJS, NextJS, MongoDB, use-react-form, SWR, and Geist-UI.

Prerequisites

Note: it's better to create a Github account first and use it to register for other services except for MongoDB. This saves you a lot of time when deploying the App.

For you to be able to follow along with this tutorial, you'll need the following:

  • Node.js and npm (yarn) installed on your machine.
  • A Github account
  • Heroku account for deployment
  • MongoDB installed on your local machine, MongoDB Cloud account for when we deploy our App
  • Vercel account for deploying our NextJS App
  • Basic React knowledge

What you'll learn

You'll learn how to setup Strapi locally for development, create API endpoints for your content, how to use MongoDB with Strapi and deploy it to Heroku with environment variables, customize default Strapi controllers.

On the frontend part, you'll learn how to use NextJS to create dynamic and static pages for your content, perform CRUD operation, handling forms with use-form-hook, add styling with Geist UI, deploy the frontend to Vercel.

What you need to know

You’ll need to know how to create Strapi App and connect it to MongoDB locally. The development of our App in 2 stages:

Installing MongoDB on our machine, creating a NextJS App, and make API calls to create, read, update and delete our content from our Strapi backend.

When you create the Strapi App, by default, all the content types you create are inaccessible to public users. You need to edit permissions to allow non-authenticated users to access your API endpoints.

The route /catalog/books/update/[id].js is a dynamic route. The id determines the Book that will be on the update page.

Setup

To install MongoDB on your local machine. Follow the ultimate guide to MongoDB for a step-by-step installation. After installing, run MongoDB and get database info.

// 1
    brew services start [email protected]

    //2 run the following command to use the mongo shell.
    mongo

    //3 To print the name of databases run
    show dbs

You will be presented with information such as below depending on the databases you have in your installation.

By default, MongoDB creates three databases. The one we're going to use is "local" to escape extra steps that are not necessary to continue our goal.

Next, install Strapi and Create a new project using the command below:

yarn create Strapi-app Strapi-local-library

Choose your installation type: select Custom (manual settings), and select your default database client: select mongo and complete the questions with the following info.

To verify that our App uses the correct database, start Strapi with yarn develop or npm run develop wait till it finishes building, then back to mongo shell and run the following command

> use local
    > show collections

Strapi will automatically open a new tap on your web browser on http://localhost:1337/
you'll be invited to create a new admin user.

After logging in, you need to create a Book, Author, Genre, and BookInstance collections. The following image describes the relation between our content types and the fields that are necessary for each type.

Let’s explain the different collections that we have created. We can translate the image like this:

  • The Author has many Books. Book has one Author
  • Book has and belongs to many Genres, Genre has and belong to many Books
  • Book has many BookInstances, BookInstance has one Book

This will help us later to define Relations in Strapi for our Content-Types. To keep the size of the tutorial as low as possible, I'll only cover the creation of the Author and the Book content types, and you can follow the same steps to finish the rest at this stage.

We have our Strapi App running and connected to a local MongoDB database.

Creating the Frontend

Create a NextJS app using any of the commands below:

npx create-next-app project-name
    # or
    yarn create next-app project-name

When it finishes installing, you will end up with a project with a folder structure similar to the following screenshot.

Next, let’s install third-party packages that will help us create our project successfully. Below is the list and description of each library.

  • useSWR is a React Hooks library for data fetching. We'll need it to fetch data on the client side.
  • luxon for formatting dates.
  • react-hook-form for forms handling

    yarn add swr luxon react-hook-form

Next, in the root folder, create config.next.js file and add to it the following code.

module.exports = {
       async redirects() {
         return [
           {
             source: '/',
             destination: '/catalog',
             permanent: true,
           },
         ]
       },
     }

Next, We will create the env.local file and add the following variable.

NEXT_PUBLIC_API_ENDPOINT = "<http://localhost:1337>"

Adding content

Before we start working on the front end, let's add some content. in the admin panel, navigate to the content-types builder and click create new collection type button, enter the Display name book as shown below, and hit Continue.

Select the Text field for the book title and click Add another field

Do the same thing for summary (long text) and ISBN (short text). For the author field, we need to create the Author content-type first. Follow previous steps to create the Author, Genre, and BookInstance content types and add a relation field for the Book.

This will automatically add the field Book to Author, Genre, and BookInstance content-types.
add some Books and Authors of your choice, or you can copy from the express-local-library tutorial.

If we try to access our API endpoint, we'll get the following message:

{
      "statusCode":403,
      "error":"Forbidden",
      "message":"Forbidden"
    }

Navigate to the following link [http://localhost:1337/admin/settings/users-permissions/roles](http://localhost:1337/admin/settings/users-permissions/roles), under public > permission > application tap check Select all options for all content types and save.

Displaying, editing contents

Next, we'll implement our booklist page. This page needs to display a list of all books in the database along with their author, with each book title being a hyperlink to its associated book detail page.

Book list page:

Create pages/catalog/books/index.js file and add the following code to it:

import Head from 'next/head'
    import Link from 'next/link'
    import { Card, Grid } from '@geist-ui/react'
    export default function Books({data, notFound}) {  
      return (
        <div>
          <Head>
            <title>Book list</title>
            <link rel='icon' href='/favicon.ico' />
          </Head>
          <section className="main-section">
            <h1>
             Books
            </h1>
            {notFound ? <div>not found</div> :
            <Grid.Container gap={1}>{
                data.map((book) => {
                   return(
                     <Grid key={book.id}>
                      <Card>
                        <Link style={{ width: '100%'}} href="/catalog/books/details/[id]" as={`/catalog/books/details/${book.id}`} >
                          <a>
                              <h4>{book.title}</h4>
                          </a>
                        </Link>
                        <p>author: {book.author.family_name} {book.author.first_name}</p>
                      </Card>
                     </Grid>
                   )
                })
             }</Grid.Container>
            }
          </section>
        </div>
      )
    }

    export async function getServerSideProps(context) {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/?_sort=updated_at:DESC`)
      const data = await res.json()

      if (!data) {
        return {
          notFound: true,
        }
      }

      return {
        props: {data}, // will be passed to the page component as props
      }
    }

We use the method provided by NextJS getServerSideProps to fetch a list of books and return it as props to our Books page component as data. Then we iterate through the list of books and render the book title and the Author.

About the Head and Link components:

  • The Head act as the document head tag
  • The Link is for navigating between routes
  • For more details about the Link component and getServerSideProps method, see the NextJS documentation at https://NextJS.org/docs/getting-started

Book detail page

The Book detail page needs to display the information for a specific Book (identified using its id field value) and information about each associated copy in the library (BookInstance). Wherever we display an author, genre, or bookInstance, these should be linked to the associated detail page for that item.

//pages/catalog/books/details/[id].js

    import { Button, Divider, Loading, Modal, Note } from '@geist-ui/react'
    import Link from 'next/link'
    import { useRouter } from 'next/router'
    import useBook from '@/hooks/useBook'

    const Book = () => {
     const router = useRouter()
     const { book, isError, isLoading } = useBook(router.query.id)
      return (
        <section className="main-section">
          {
          isError ? "an error occured !" : isLoading ? <Loading /> :
          <div>
             <div>
                <h2>Title:</h2><p>{book.title}</p>
             </div>
             <div>
                <h2>ISBN:</h2><p>{book.ISBN}</p>
             </div>
             <div>
                <h2>Author:</h2> <p>{book.author.family_name} {book.author.first_name}</p>
             </div>
             <div>
                <h2>Summary:</h2><p>{book.summary}</p>
             </div>
             <div>
                <h2>Genre:</h2>
                <div>
                   {
                   book.genres.length > 0 ? book.genres.map(({name, id}) => {
                      return(
                         <div key={id}>
                            <p>{name}</p>
                         </div>
                      )
                   })
                   : 
                   'this book dont belong to any genre'
                   }
                </div>
             </div>
             <div>
                <h2>Copies:</h2>
                <ul>
                   {
                   book.bookinstances.length > 0 ? book.bookinstances.map(({imprint, status, id}) => {
                      return(
                         <li key={id}>
                            <span> {imprint} </span>
                            <span className={status}> [ {status}  ]</span>
                         </li>
                      )
                   })
                   : 
                   'there are no copies of this book in the library'
                   }
                </ul>
             </div>
          </div>
          }
        </section>
      )
    }

    export default Book

About the UseRouter and useBook hooks:

  • useRouter hook comes with NextJS. It allows us to access the router object inside any function component in our App.
  • From the router object, we can access the query property that contains our book id router.query.id, then we use the book id to fetch a specific book using our custom useBook hook
  • useBook hook is a custom hook that receives an id and initialBook object as parameters and returns a book object matching that id.
// hooks/useBook.js

    import useSWR from 'swr'
    import Fetcher from '../utils/Fetcher'

    function useBook (id, initialBook) {
       const { data, error } = useSWR(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${id}`, Fetcher, { initialData: initialBook })

       return {
         book: data,
         isLoading: !error && !data,
         isError: error
       }
     }
     export default useBook

useBook hook is built on top of SWR, a React Hooks library for data fetching. For more information about how to use it with Next.js, refer to the official docs https://swr.vercel.app/docs/with-NextJS.

Create book page

For this page we need to get and display available Author and Genre records in our Book form.

import { useState } from 'react'
    import Head from 'next/head'
    import { useRouter } from 'next/router'
    import { Button, Loading, Spacer } from '@geist-ui/react'
    import useAuthors from '@/hooks/useAuthors'
    import useGenres from '@/hooks/useGenres'
    import { useForm } from "react-hook-form"

    export default function CreateBook() {
       const router = useRouter()
       const { authors, isError: isAuthorError, isLoading: authorsIsLoading } = useAuthors({initialData: null})
       const { genres, isError: isGenreError, isLoading: genresIsLoading } = useGenres()
       const { register, handleSubmit } = useForm({mode: "onChange"});

       async function createBook(data){
          const res = await fetch(
             `${process.env.NEXT_PUBLIC_API_ENDPOINT}/books`,
             {
               body: JSON.stringify({
                title: data.title,
                author: data.author,
                summary: data.summary,
                genres: data.genre,
                ISBN: data.ISBN,
               }),
               headers: {
                 'Content-Type': 'application/json'
               },
               method: 'POST'
             }
          )
          const result = await res.json()
          if(res.ok){
            router.push(`/catalog/books/details/${result.id}`)
          }
       }
      return (
        <div>
          <Head>
            <title>Create new Book</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <section className="main-section">
            <h1>
             New Book
            </h1>
            {
             isAuthorError || isGenreError ? "An error has occurred."
             : authorsIsLoading || genresIsLoading ? <Loading />
             :
            <form id="Book-form" onSubmit={handleSubmit(createBook)}>
               <div>
                <label htmlFor="title">Title</label>
                <input type="text" name="title" id="title" {...register('title')}/>
               </div>
               <Spacer y={1}/>
               <div>
                <label htmlFor="author">Author</label>
                <select type="text" name="author" id="author" {...register('author')}>
                {authors.map((author) => {
                   return(
                      <option key={author.id} value={author.id}>
                         {author.first_name + " " + author.family_name}
                      </option>
                   )
                })}
                </select>
               </div>
               <Spacer y={1}/>
               <div>
                <label htmlFor="summary">Summary</label>
                <textarea name="summary" id="summary" {...register('summary')}/>
               </div>
               <Spacer y={1}/>
               <div>
                 {genres.length > 0 ?
                   genres.map((genre) => {
                     return(
                       <div key={genre.id}>
                          <input
                            type="checkbox"
                            value={genre.id}
                            id={genre.id}
                            {...register("genre")}
                          />
                        <label htmlFor={genre.id}>{genre.name}</label>
                      </div>
                     )
                   })
                  : null
                 }
               </div>
               <Spacer y={1}/>
               <div>
                <label htmlFor="ISBN">ISBN</label>
                <input type="text" name="ISBN" id="ISBN" {...register('ISBN')}/>
                {ISBNError && 
                <div style={{
                  fontSize:"12px",
                  padding:"8px",
                  color: "crimson"}}>
                    book with same ISBN already exist
                </div>}
               </div>
               <Spacer y={2}/>
               <Button htmlType="submit" type="success" ghost>Submit</Button>
            </form>
          }
          </section>
        </div>
      )
    }

This page's code is structured as the following:

First, we create the form with the required fields for creating an Author. Then we use the useForm hook to register the fields and handle the form submission.

Then we fetch the Genres and Authors to pre-populate our inputs (checkbox, select). The last step is to call the function createBook to handle the API call to create a new Book and redirect the user to the book detail page.

Update Book page

Updating a book is much like that for creating a book, except that we must populate the form with values from the database.

import { useEffect } from 'react'
    import Head from 'next/head'
    import { Button, Loading, Spacer } from '@geist-ui/react'
    import { withRouter } from 'next/router'
    import useGenres from '@/hooks/useGenres'
    import useAuthors from '@/hooks/useAuthors'
    import useBook from '@/hooks/useBook'
    import { useForm } from "react-hook-form"

    function UpdateBook({ router, initialBook }) {
      const { id } = router.query
      // fetching book and genres to populate Author field and display all the genres.
      const {genres, isLoading: genresIsLoading, isError: genresIsError} = useGenres()
      const {authors, isLoading: authorsIsLoading, isError: AuthorsIsError} = useAuthors({initialData: null})
      const { book, isError, isLoading } = useBook(router.query.id ? router.query.id : null, initialBook)

      // register form fields 
      const { register, handleSubmit, reset } = useForm({mode: "onChange"});

      useEffect(() => {
        const bookGenres = book.genres.map((genre) => {
          let ID = genre.id.toString()
          return ID
        })
        reset({
          title: book.title,
          author: book.author.id,
          summary: book.summary,
          ISBN: book.ISBN,
          genre: bookGenres
        });
      }, [reset])

      // API Call Update Book
      async function updateBook(data){
        const res = await fetch(
            `${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${id}`,
            {
              method: 'PUT',
              headers: {
                'Content-Type': 'application/json'
              },
              body: JSON.stringify({
              title: data.title,
              author: data.author,
              summary: data.summary,
              genres: data.genre,
              ISBN: data.ISBN,
              })
            }
        )
        router.push(`/catalog/books/details/${id}`)
      }
      return (
        <div>
          <Head>
            <title>Update Book</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <section className="main-section">
            <h1>
             Update Book
            </h1>
            {
              genresIsError || AuthorsIsError ? "an error occured" : genresIsLoading || authorsIsLoading ? <Loading /> :  
            <form id="Book-update-form" onSubmit={handleSubmit(updateBook)}>
               <div>
                <label htmlFor="title">Title</label>
                <input type="text"  id="title" {...register("title")}/>
               </div>
               <Spacer y={1}/>
               <div>
                <label htmlFor="author">Author</label>
                <select type="text"  id="author" {...register("author")}>
                  {authors.map((author) => {
                    return(
                        <option key={author.id} value={author.id}>
                          {author.first_name + " " + author.family_name}
                        </option>
                    )
                  })}
                </select>
               </div>
               <Spacer y={1}/>
               <div>
                <label htmlFor="summary" >Summary</label>
                <textarea id="summary" {...register("summary")}/>
               </div>
               <Spacer y={1}/>
               <div>
                <label htmlFor="ISBN">ISBN</label>
                <input type="text" id="ISBN" {...register("ISBN")}/>
               </div>
               <Spacer y={1}/>
               <div>
                 {genres.length > 0 ?
                   genres.map((genre) => {
                     return(
                       <div key={genre.id}>
                          <input
                            type="checkbox"
                            value={genre.id}
                            id={genre.id}
                            {...register("genre")}
                          />
                        <label htmlFor={genre.id}>{genre.name}</label>
                      </div>
                     )
                   })
                  : null
                 }
               </div>
               <Spacer y={2}/>
               <Button auto htmlType="submit" type="success" ghost>Submit</Button>
            </form>
            }
          </section>
        </div>
      )
    }

    export default withRouter(UpdateBook)

    export async function getStaticPaths() {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books`)
      const books = await res.json()
      const paths = books.map((book) => ({
        params: { id: book.id.toString() },
      }))

      return { paths, fallback: false }
    }

    export async function getStaticProps({ params }) {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${params.id}`)
      const initialBook = await res.json()
      return {
        props: {
          initialBook,
        },
      }
    }

This page's code structure is almost the same as for creating a book page, the differences are:

  • The route is dynamic
  • Because we are using the getStaticProps method, the page is pre-rendered with Static Generation (SSG).
  • This time we're using the reset method that useForm hook gives us to populate our form fields within the useEffect hook

Delete book page

We'll be adding the delete functionality in the book detail page, open the pages/catalog/books/details/[id].js file and update it with the following code

...
    const Book = () => {
    ...
            const [toggleModal, setToggleModal] = useState(false)
            const handler = () => setToggleModal(true)
               const closeHandler = (event) => {
                  setToggleModal(false)
               }
               async function DeleteBook() {
                  const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${router.query.id}`,
                  {
                     method:"DELETE",
                     headers: {
                        'Content-Type' : 'application/json'
                     },
                     body: null
                  })
                  setToggleModal(false)
                  router.push(`/catalog/books`)
               }
              return (
                            <section className="main-section">
          {
                  isError ? "an error occured !" : isLoading ? <Loading /> :
                  <div>
                                    ...
                       <Divider />
           <Button style={{marginRight:"1.5vw"}} auto onClick={handler} type="error">Delete book</Button>
           <Link href={`/catalog/books/update/${book.id}`}>
              <a>
                 <Button auto type="default">Update book</Button>
              </a>
           </Link>
           <Modal open={toggleModal} onClose={closeHandler}>
              {book.bookinstances.length > 0 ?
              <>
              <Modal.Title>
                 <Note type="warning">delete the following copies before deleting this book</Note>
              </Modal.Title>
              <Modal.Content>
                 <ul>
                    {book.bookinstances.map((copie) => {
                       return(
                          <li key={copie.id}>{copie.imprint}, #{copie.id}</li>
                       )
                    })
                    }
                 </ul>
              </Modal.Content>
              </>
              :<>
              <Modal.Title>CONFIRM DELETE BOOK ?</Modal.Title>
              <Modal.Subtitle>This action is ireversible</Modal.Subtitle>
              </>
              }
              <Modal.Action passive onClick={() => setToggleModal(false)}>Cancel</Modal.Action>
              <Modal.Action disabled={book.bookinstances.length > 0} onClick={DeleteBook}>Confirm</Modal.Action>
           </Modal>
                    </div>
          }
        </section>
      )
    }

    export default Book

Here we're adding the delete functionality to the detail page by adding a delete button toggle a modal component.

In the modal, we'll check if the Book has at least one BookInstance. We'll prevent the user from deleting this Book and showing a list of BookInstances that must be deleted before deleting the Book. If the Book has no BookInstances, we call the DeleteBook function when the user confirms.

Backend customization

For more information about backend customization, I recommend reading the official Strapi docs link. Open Strapi App in visual studio code and open the book controller file.

Add the following code and save:

// api/book/controllers/book.js

    const { sanitizeEntity } = require('Strapi-utils');

    module.exports = {
      async delete (ctx) {
        const { id } = ctx.params;
        let entity = await Strapi.services.book.find({ id });
        if(entity[0].bookinstances.length > 0) {
            return ctx.send({
              message: 'book contain one or more instances'
          }, 406);
        }
        entity = await Strapi.services.book.delete({ id });
        return sanitizeEntity(entity, { model: Strapi.models.book });
      },

      async create(ctx) {
        let entity;
        const { ISBN } = ctx.request.body
        entity = await Strapi.services.book.findOne({ ISBN });
        if (entity){
            return ctx.send({
              message: 'book alredy existe'
          }, 406);
        }
        if (ctx.is('multipart')) {
          const { data, files } = parseMultipartData(ctx);
          entity = await Strapi.services.book.create(data, { files });
        } else {
          entity = await Strapi.services.book.create(ctx.request.body);
        }
        return sanitizeEntity(entity, { model: Strapi.models.book });
      },
    };

In this file, we're overriding the default delete route by checking if the Book has at least one bookInstance we respond with a 406 not Acceptable error and a message, else we allow to delete the Book.

For the create route, we are checking if a book with the same ISBN already exists. We respond with a 406 not Acceptable error and a message. Else we allow creating a new book.

Deployment

Deploy Strapi app

To deploy Strapi app, we will create and host our MongoDB instance on the cloud.

Create a database on MongoDB cloud
Assuming you have a MongoDB account, I recommend following the official tutorial on creating a New Cluster on the MongoDB website. After creating a new cluster, navigate to clusters > your cluster name > collections and click on

You can choose any name for your database. After creating the database, under SECURITY > Database access, create a new user and make sure to save the password for later uses, and the next step is to click on connect.

Lastly, choose to connect your application, you'll get the database connection string

We will use this string to connect our Strapi App to the database.

Create a new App on Heroku

Login to Heroku and click on the top right corner

After creating the App, go to setting > config vars and click Reveal Config Vars
add config vars as follow.

All Config Vars are related to the database information.

  • You can extract the DATABASE_HOST variable from the database connection string following the pattern shown in the picture above.
  • DATABASE_NAME is the name of the MongoDB database you created earlier.
  • Same for DATABASE_PASSWORD and DATABASE_USERNAME.

Now we can deploy our Strapi App directly from the Heroku dashboard, go to deploy tap, and choose Github as the following

Choose the right GitHub repo and click on Connect.

After Heroku completes connecting to the repository, deploy the App.

Deploy NextJS app

Connect to your Vercel account, select projects, tap and click on New Project, then import the repo you want to deploy. You'll be redirected to the project configuration page.

Add the following Environment Variable to your Vercel account.

VARIABLE_NAME = NEXT_PUBLIC_API_ENDPOINT
    VALUE = https://your-app-name.herokuapp.com

Next, click the deploy button.

That's the end of this tutorial on Rebuild the MDN express local library website with Strapi and NextJS.

Conclusion

In this tutorial, we learned how to install Strapi with MongoDB locally and customize our API endpoint to our needs.

We also learned how to create a NextJS app and communicate with our API to perform CRUD operation with NextJS built-in functionality and use environment variables to deploy our Strapi and NextJS application to Heroku and Vercel.

Next, I propose to extend this App by adding Authentication and user registration with the NextAuth package. I recommend this article by Osmar Pérez.

48