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.
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:
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.
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.
NextJS has a file-based routing system where each page automatically becomes a route based on its file name. For example, a page at pages/books
will be located at /books
, pages/index.js
at /
, and pages/books/index.js
is equivalent to pages/books.js
.
The route /catalog/books/update/[id].js
is a dynamic route. The id
determines the Book
that will be on the update page.
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 // 1
2 brew services start mongodb-community@4.4
3
4 //2 run the following command to use the mongo shell.
5 mongo
6
7 //3 To print the name of databases run
8 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:
1 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
1 > use local
2 > 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:
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.
Create a NextJS app using any of the commands below:
1 npx create-next-app project-name
2 # or
3 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-formNext, in the root folder, create config.next.js
file and add to it the following code.
1 module.exports = {
2 async redirects() {
3 return [
4 {
5 source: '/',
6 destination: '/catalog',
7 permanent: true,
8 },
9 ]
10 },
11 }
Next, We will create the env.local
file and add the following variable.
1 NEXT_PUBLIC_API_ENDPOINT = "<http://localhost:1337>"
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:
1 {
2 "statusCode":403,
3 "error":"Forbidden",
4 "message":"Forbidden"
5 }
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.
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.
Create pages/catalog/books/index.js
file and add the following code to it:
1 import Head from 'next/head'
2 import Link from 'next/link'
3 import { Card, Grid } from '@geist-ui/react'
4 export default function Books({data, notFound}) {
5 return (
6 <div>
7 <Head>
8 <title>Book list</title>
9 <link rel='icon' href='/favicon.ico' />
10 </Head>
11 <section className="main-section">
12 <h1>
13 Books
14 </h1>
15 {notFound ? <div>not found</div> :
16 <Grid.Container gap={1}>{
17 data.map((book) => {
18 return(
19 <Grid key={book.id}>
20 <Card>
21 <Link style={{ width: '100%'}} href="/catalog/books/details/[id]" as={`/catalog/books/details/${book.id}`} >
22 <a>
23 <h4>{book.title}</h4>
24 </a>
25 </Link>
26 <p>author: {book.author.family_name} {book.author.first_name}</p>
27 </Card>
28 </Grid>
29 )
30 })
31 }</Grid.Container>
32 }
33 </section>
34 </div>
35 )
36 }
37
38 export async function getServerSideProps(context) {
39 const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/?_sort=updated_at:DESC`)
40 const data = await res.json()
41
42 if (!data) {
43 return {
44 notFound: true,
45 }
46 }
47
48 return {
49 props: {data}, // will be passed to the page component as props
50 }
51 }
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
.
Head
act as the document head tagLink
is for navigating between routesLink
component and getServerSideProps
method, see the NextJS documentation at https://NextJS.org/docs/getting-startedThe 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.
1 //pages/catalog/books/details/[id].js
2
3 import { Button, Divider, Loading, Modal, Note } from '@geist-ui/react'
4 import Link from 'next/link'
5 import { useRouter } from 'next/router'
6 import useBook from '@/hooks/useBook'
7
8 const Book = () => {
9 const router = useRouter()
10 const { book, isError, isLoading } = useBook(router.query.id)
11 return (
12 <section className="main-section">
13 {
14 isError ? "an error occured !" : isLoading ? <Loading /> :
15 <div>
16 <div>
17 <h2>Title:</h2><p>{book.title}</p>
18 </div>
19 <div>
20 <h2>ISBN:</h2><p>{book.ISBN}</p>
21 </div>
22 <div>
23 <h2>Author:</h2> <p>{book.author.family_name} {book.author.first_name}</p>
24 </div>
25 <div>
26 <h2>Summary:</h2><p>{book.summary}</p>
27 </div>
28 <div>
29 <h2>Genre:</h2>
30 <div>
31 {
32 book.genres.length > 0 ? book.genres.map(({name, id}) => {
33 return(
34 <div key={id}>
35 <p>{name}</p>
36 </div>
37 )
38 })
39 :
40 'this book dont belong to any genre'
41 }
42 </div>
43 </div>
44 <div>
45 <h2>Copies:</h2>
46 <ul>
47 {
48 book.bookinstances.length > 0 ? book.bookinstances.map(({imprint, status, id}) => {
49 return(
50 <li key={id}>
51 <span> {imprint} </span>
52 <span className={status}> [ {status} ]</span>
53 </li>
54 )
55 })
56 :
57 'there are no copies of this book in the library'
58 }
59 </ul>
60 </div>
61 </div>
62 }
63 </section>
64 )
65 }
66
67 export default Book
u``seRouter
hook comes with NextJS. It allows us to access the router object inside any function component in our App.router.query.id
, then we use the book id to fetch a specific book using our custom useBook
hookuseBook
hook is a custom hook that receives an id
and initialBook
object as parameters and returns a book object matching that id
.1 // hooks/useBook.js
2
3 import useSWR from 'swr'
4 import Fetcher from '../utils/Fetcher'
5
6 function useBook (id, initialBook) {
7 const { data, error } = useSWR(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${id}`, Fetcher, { initialData: initialBook })
8
9 return {
10 book: data,
11 isLoading: !error && !data,
12 isError: error
13 }
14 }
15 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.
For this page we need to get and display available Author and Genre records in our Book form.
1 import { useState } from 'react'
2 import Head from 'next/head'
3 import { useRouter } from 'next/router'
4 import { Button, Loading, Spacer } from '@geist-ui/react'
5 import useAuthors from '@/hooks/useAuthors'
6 import useGenres from '@/hooks/useGenres'
7 import { useForm } from "react-hook-form"
8
9 export default function CreateBook() {
10 const router = useRouter()
11 const { authors, isError: isAuthorError, isLoading: authorsIsLoading } = useAuthors({initialData: null})
12 const { genres, isError: isGenreError, isLoading: genresIsLoading } = useGenres()
13 const { register, handleSubmit } = useForm({mode: "onChange"});
14
15 async function createBook(data){
16 const res = await fetch(
17 `${process.env.NEXT_PUBLIC_API_ENDPOINT}/books`,
18 {
19 body: JSON.stringify({
20 title: data.title,
21 author: data.author,
22 summary: data.summary,
23 genres: data.genre,
24 ISBN: data.ISBN,
25 }),
26 headers: {
27 'Content-Type': 'application/json'
28 },
29 method: 'POST'
30 }
31 )
32 const result = await res.json()
33 if(res.ok){
34 router.push(`/catalog/books/details/${result.id}`)
35 }
36 }
37 return (
38 <div>
39 <Head>
40 <title>Create new Book</title>
41 <link rel="icon" href="/favicon.ico" />
42 </Head>
43 <section className="main-section">
44 <h1>
45 New Book
46 </h1>
47 {
48 isAuthorError || isGenreError ? "An error has occurred."
49 : authorsIsLoading || genresIsLoading ? <Loading />
50 :
51 <form id="Book-form" onSubmit={handleSubmit(createBook)}>
52 <div>
53 <label htmlFor="title">Title</label>
54 <input type="text" name="title" id="title" {...register('title')}/>
55 </div>
56 <Spacer y={1}/>
57 <div>
58 <label htmlFor="author">Author</label>
59 <select type="text" name="author" id="author" {...register('author')}>
60 {authors.map((author) => {
61 return(
62 <option key={author.id} value={author.id}>
63 {author.first_name + " " + author.family_name}
64 </option>
65 )
66 })}
67 </select>
68 </div>
69 <Spacer y={1}/>
70 <div>
71 <label htmlFor="summary">Summary</label>
72 <textarea name="summary" id="summary" {...register('summary')}/>
73 </div>
74 <Spacer y={1}/>
75 <div>
76 {genres.length > 0 ?
77 genres.map((genre) => {
78 return(
79 <div key={genre.id}>
80 <input
81 type="checkbox"
82 value={genre.id}
83 id={genre.id}
84 {...register("genre")}
85 />
86 <label htmlFor={genre.id}>{genre.name}</label>
87 </div>
88 )
89 })
90 : null
91 }
92 </div>
93 <Spacer y={1}/>
94 <div>
95 <label htmlFor="ISBN">ISBN</label>
96 <input type="text" name="ISBN" id="ISBN" {...register('ISBN')}/>
97 {ISBNError &&
98 <div style={{
99 fontSize:"12px",
100 padding:"8px",
101 color: "crimson"}}>
102 book with same ISBN already exist
103 </div>}
104 </div>
105 <Spacer y={2}/>
106 <Button htmlType="submit" type="success" ghost>Submit</Button>
107 </form>
108 }
109 </section>
110 </div>
111 )
112 }
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.
Updating a book is much like that for creating a book, except that we must populate the form with values from the database.
1 import { useEffect } from 'react'
2 import Head from 'next/head'
3 import { Button, Loading, Spacer } from '@geist-ui/react'
4 import { withRouter } from 'next/router'
5 import useGenres from '@/hooks/useGenres'
6 import useAuthors from '@/hooks/useAuthors'
7 import useBook from '@/hooks/useBook'
8 import { useForm } from "react-hook-form"
9
10 function UpdateBook({ router, initialBook }) {
11 const { id } = router.query
12 // fetching book and genres to populate Author field and display all the genres.
13 const {genres, isLoading: genresIsLoading, isError: genresIsError} = useGenres()
14 const {authors, isLoading: authorsIsLoading, isError: AuthorsIsError} = useAuthors({initialData: null})
15 const { book, isError, isLoading } = useBook(router.query.id ? router.query.id : null, initialBook)
16
17 // register form fields
18 const { register, handleSubmit, reset } = useForm({mode: "onChange"});
19
20 useEffect(() => {
21 const bookGenres = book.genres.map((genre) => {
22 let ID = genre.id.toString()
23 return ID
24 })
25 reset({
26 title: book.title,
27 author: book.author.id,
28 summary: book.summary,
29 ISBN: book.ISBN,
30 genre: bookGenres
31 });
32 }, [reset])
33
34 // API Call Update Book
35 async function updateBook(data){
36 const res = await fetch(
37 `${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${id}`,
38 {
39 method: 'PUT',
40 headers: {
41 'Content-Type': 'application/json'
42 },
43 body: JSON.stringify({
44 title: data.title,
45 author: data.author,
46 summary: data.summary,
47 genres: data.genre,
48 ISBN: data.ISBN,
49 })
50 }
51 )
52 router.push(`/catalog/books/details/${id}`)
53 }
54 return (
55 <div>
56 <Head>
57 <title>Update Book</title>
58 <link rel="icon" href="/favicon.ico" />
59 </Head>
60 <section className="main-section">
61 <h1>
62 Update Book
63 </h1>
64 {
65 genresIsError || AuthorsIsError ? "an error occured" : genresIsLoading || authorsIsLoading ? <Loading /> :
66 <form id="Book-update-form" onSubmit={handleSubmit(updateBook)}>
67 <div>
68 <label htmlFor="title">Title</label>
69 <input type="text" id="title" {...register("title")}/>
70 </div>
71 <Spacer y={1}/>
72 <div>
73 <label htmlFor="author">Author</label>
74 <select type="text" id="author" {...register("author")}>
75 {authors.map((author) => {
76 return(
77 <option key={author.id} value={author.id}>
78 {author.first_name + " " + author.family_name}
79 </option>
80 )
81 })}
82 </select>
83 </div>
84 <Spacer y={1}/>
85 <div>
86 <label htmlFor="summary" >Summary</label>
87 <textarea id="summary" {...register("summary")}/>
88 </div>
89 <Spacer y={1}/>
90 <div>
91 <label htmlFor="ISBN">ISBN</label>
92 <input type="text" id="ISBN" {...register("ISBN")}/>
93 </div>
94 <Spacer y={1}/>
95 <div>
96 {genres.length > 0 ?
97 genres.map((genre) => {
98 return(
99 <div key={genre.id}>
100 <input
101 type="checkbox"
102 value={genre.id}
103 id={genre.id}
104 {...register("genre")}
105 />
106 <label htmlFor={genre.id}>{genre.name}</label>
107 </div>
108 )
109 })
110 : null
111 }
112 </div>
113 <Spacer y={2}/>
114 <Button auto htmlType="submit" type="success" ghost>Submit</Button>
115 </form>
116 }
117 </section>
118 </div>
119 )
120 }
121
122 export default withRouter(UpdateBook)
123
124 export async function getStaticPaths() {
125 const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books`)
126 const books = await res.json()
127 const paths = books.map((book) => ({
128 params: { id: book.id.toString() },
129 }))
130
131 return { paths, fallback: false }
132 }
133
134 export async function getStaticProps({ params }) {
135 const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${params.id}`)
136 const initialBook = await res.json()
137 return {
138 props: {
139 initialBook,
140 },
141 }
142 }
This page's code structure is almost the same as for creating a book page, the differences are:
getStaticProps
method, the page is pre-rendered with Static Generation (SSG).useForm
hook gives us to populate our form fields within the useEffect
hookDelete 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
1 ...
2 const Book = () => {
3 ...
4 const [toggleModal, setToggleModal] = useState(false)
5 const handler = () => setToggleModal(true)
6 const closeHandler = (event) => {
7 setToggleModal(false)
8 }
9 async function DeleteBook() {
10 const res = await fetch(`${process.env.NEXT_PUBLIC_API_ENDPOINT}/books/${router.query.id}`,
11 {
12 method:"DELETE",
13 headers: {
14 'Content-Type' : 'application/json'
15 },
16 body: null
17 })
18 setToggleModal(false)
19 router.push(`/catalog/books`)
20 }
21 return (
22 <section className="main-section">
23 {
24 isError ? "an error occured !" : isLoading ? <Loading /> :
25 <div>
26 ...
27 <Divider />
28 <Button style={{marginRight:"1.5vw"}} auto onClick={handler} type="error">Delete book</Button>
29 <Link href={`/catalog/books/update/${book.id}`}>
30 <a>
31 <Button auto type="default">Update book</Button>
32 </a>
33 </Link>
34 <Modal open={toggleModal} onClose={closeHandler}>
35 {book.bookinstances.length > 0 ?
36 <>
37 <Modal.Title>
38 <Note type="warning">delete the following copies before deleting this book</Note>
39 </Modal.Title>
40 <Modal.Content>
41 <ul>
42 {book.bookinstances.map((copie) => {
43 return(
44 <li key={copie.id}>{copie.imprint}, #{copie.id}</li>
45 )
46 })
47 }
48 </ul>
49 </Modal.Content>
50 </>
51 :<>
52 <Modal.Title>CONFIRM DELETE BOOK ?</Modal.Title>
53 <Modal.Subtitle>This action is ireversible</Modal.Subtitle>
54 </>
55 }
56 <Modal.Action passive onClick={() => setToggleModal(false)}>Cancel</Modal.Action>
57 <Modal.Action disabled={book.bookinstances.length > 0} onClick={DeleteBook}>Confirm</Modal.Action>
58 </Modal>
59 </div>
60 }
61 </section>
62 )
63 }
64
65 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.
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:
1 // api/book/controllers/book.js
2
3 const { sanitizeEntity } = require('Strapi-utils');
4
5 module.exports = {
6 async delete (ctx) {
7 const { id } = ctx.params;
8 let entity = await Strapi.services.book.find({ id });
9 if(entity[0].bookinstances.length > 0) {
10 return ctx.send({
11 message: 'book contain one or more instances'
12 }, 406);
13 }
14 entity = await Strapi.services.book.delete({ id });
15 return sanitizeEntity(entity, { model: Strapi.models.book });
16 },
17
18 async create(ctx) {
19 let entity;
20 const { ISBN } = ctx.request.body
21 entity = await Strapi.services.book.findOne({ ISBN });
22 if (entity){
23 return ctx.send({
24 message: 'book alredy existe'
25 }, 406);
26 }
27 if (ctx.is('multipart')) {
28 const { data, files } = parseMultipartData(ctx);
29 entity = await Strapi.services.book.create(data, { files });
30 } else {
31 entity = await Strapi.services.book.create(ctx.request.body);
32 }
33 return sanitizeEntity(entity, { model: Strapi.models.book });
34 },
35 };
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.
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.
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.
1 VARIABLE_NAME = NEXT_PUBLIC_API_ENDPOINT
2 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.
Source Code: NextJS - https://github.com/okuninoshi/next-local-library.git
Strapi - https://github.com/okuninoshi/Strapi-local-library.git
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.
An automation and Frontend Engineer