Building A Web Application In 15 Minutes Using StrapiJS And NextJS

Julien Demangeon
Julien DemangeonJune 18, 2020
#js#tutorial

API-centric architectures tend to be the dominant paradigm for the development of new web applications. The backend part (setting up an API linked to a relational database) is mostly a solved problem, using either performant frameworks, or no-code tools. Here is my experience setting up such a backend with Strapi, an open-source headless CMS. Spoiler: I loved it.

The Evolution Of Content Management Systems

For a long time, data was strongly coupled to its display and more generally to its support. Many sites were directly connected to their own database, itself fed by a back-office. This was the case for example of many blog engines and CMS such as Wordpress, Drupal, Joomla and so on.

With the diversification of communication mediums, more and more companies have felt the need to centralize their content in order to distribute it as widely as possible. This trend was accentuated by the rapid migration of developments from the backend to the frontend with the emergence of JavaScript and Single Page Applications.

This is how decentralized editorial content management systems (known as Headless CMS) appeared, allowing content to be edited and widely distributed on all media from a single API location.

In this article, we are going to explore StrapiJS, an Headless CMS which as you will see, does not lack features...

The Rise Of The Headless CMS

Just like StrapiJS, all Headless CMS allow you to model and create content without worrying about its use, providing many advantages:

  • The content can be used on any medium
  • Can easily be hosted on the cloud and scale quickly
  • The editorial team can edit content without worrying about the progress of related projects
  • Forces editors to create structured, reusable content
  • Offers more security. The CMS is not reached if the media using it is hacked

The other side of the coin is that the CMS becomes a SPOF and has to be heavily protected.

Among the best-known headless CMS, there are:

  • Directus (Open-Source, self-hosted or SaaS, Full-Featured, PHP based)
  • Cockpit (Open-Source, Lightweight, PHP based)
  • Contentful (SaaS, Many features (ecommerce, CDN, ...), rather expensive)
  • ButterCMS (almost similar as Contentful)
  • Sanity (almost similar as Contentful)

Wordpress and Drupal have a REST API, but are not yet at the level of other Headless CMS. Their API are rather a complementary solution when it is necessary to deliver content from an existing site.

StrapiJS is a French (cocorico!), open-source Headless CMS published for the first time in 2015 and which is strongly gaining in popularity since the beginning of 2020. It has many features such as:

  • An intuitive backoffice interface
  • A rich markdown editor
  • Numerous field types allowing infinite combinations of data structures
  • A customizable / Versionnable API
  • A rich authentication / Permission system (using JWT)
  • Allows both to use GraphQL or REST to get data
  • An integrated e-mailing system (for registration, password reset, ...)
  • Webhooks (to re-generate a static website on demand for example)
  • A backend agnostic file-upload system (providers are configurable)
  • A Plug'n'Play database system (you can use SQLite, MySQL, Postgres, MongoDB...)

That's a lot, and it's not over! StrapiJS even provides a system of plugins.

Practical Case: The Beerdex App

In most of my hackday projects, I try to find a use case that allows me to cover all the discovered features. Today, I will explore StrapiJS through a beer management application.

Snap, classify, consume. Gotta catch (and drink) 'em all!

Project Setup

This project consists in setting up a backend with our Headless API (StrapiJS) and a frontend in ReactJS (using NextJS). To start, I'm going to create 2 folders that will be used in a mono-repository.

mkdir -p beerdex/{backend,frontend}
cd beerdex 

As part of a regular StrapiJS project, the following yarn create or npx create commands can be used to initialize our file structure.

npx create-strapi-app my-project
yarn create strapi-app my-project

In our case and to stay concise, I'll use a ready-made docker image in the docker-compose.yml file and map its volume to the backend folder. I will also expose port 1337, which is the default port used by StrapiJS for the API (REST or GraphQL) and the backoffice.

version: '3'
services:
    backend:
        image: strapi/strapi
        volumes:
            - ./backend:/srv/app
        ports:
            - '1337:1337'

By default, StrapiJS will use SQlite as database, but it is also possible to use adapters for PostgreSQL, MySQL, MariaDB or MongoDB.

As usual, I add a Makefile to manage all this more easily and start the stack using make install start.

DOCKER_COMPOSE = docker-compose

help:
	@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

install: ## Install the application
	${DOCKER_COMPOSE} pull

start: ## Start the application
	${DOCKER_COMPOSE} up -d

stop: ## Stop the application
	${DOCKER_COMPOSE} down

logs: ## Display the containers logs
	${DOCKER_COMPOSE} logs -f

Once the project is started, we notice that StrapiJS took care of creating the file structure for us in the backend/ directory:

julien@computer:~/Projects/beerdex/backend$ tree -L 1
.
├── api
├── build
├── config
├── extensions
├── favicon.ico
├── node_modules
├── package.json
├── public
├── README.md
└── yarn.lock

6 directories, 4 files

Now that the project structure is set up, let's initialize our git repository (git init) in order to keep track of our original repository and follow the changes.

If we take a look at the logs, we can see that StrapiJS also took care of installing the npm modules and starting the server.

julien@computer:~/Projects/beerdex$ make logs
docker-compose logs -f
Attaching to beerdex_backend_1
Starting your app...
Using strapi 3.0.0-beta.19.3
No project found at /srv/app. Creating a new strapi project
Creating a new Strapi application at /srv/app.

Creating a project from the database CLI arguments.
Creating files.
- Installing dependencies:
Dependencies installed successfully.

Your application was created at /srv/app.

Available commands in your project:

  yarn develop
  Start Strapi in watch mode.

  yarn start
  Start Strapi without watch mode.

  yarn build
  Build Strapi admin panel.

  yarn strapi
  Display all available commands.

You can start by doing:

  cd /srv/app
  yarn develop

Starting your app...
Building your admin UI with development configuration ...
ℹ Compiling Webpack
✔ Webpack: Compiled successfully in 25.03s
[2020-03-24T13:45:58.798Z] warn The bootstrap function is taking unusually long to execute (3500miliseconds).
[2020-03-24T13:45:58.799Z] warn Make sure you call it?
[2020-03-24T13:46:02.233Z] info File created: /srv/app/extensions/users-permissions/config/jwt.json

Project information

┌────────────────────┬──────────────────────────────────────────────────┐
│ Time               │ Tue Mar 24 2020 13:46:02 GMT+0000 (Coordinated … │
│ Launched in8696 ms                                          │
│ Environment        │ development                                      │
│ Process PID        │ 110                                              │
│ Version            │ 3.0.0-beta.19.3 (node v12.16.1)                  │
└────────────────────┴──────────────────────────────────────────────────┘

Actions available

One more thing...
Create your first administrator 💻 by going to the administration panel at:

┌─────────────────────────────┐
│ http://localhost:1337/admin │
└─────────────────────────────┘

[2020-03-24T13:46:02.304Z] debug HEAD /admin (12 ms) 200
[2020-03-24T13:46:02.308Z] info ⏳ Opening the admin panel...

Now that the project is started, we just have to go to the admin dashboard url (http://localhost:1337/admin) to initialize our admin account as described in the logs.

StrapiJS Dashboard

Once logged in, we have direct access to the dashboard and the different functionalities. In the top left corner, our data collections with the user table are already initialized.

Further down in the menu, the edition of data types, uploaded files and permissions management sections help with more advanced usage scenarios. The configuration of webhooks and plugins can be found last in the "general" section.

Data Modeling

Now that StrapiJS is ready, I can model the data using the content type editor. First, I will create the beer collection type, which is the main model of the application.

Collection Type

Then I add the few necessary fields and click save.

Beer Model

StrapiJS restarts to apply its changes... why?

In reality, StrapiJS does not just create a new data model in the database. It also creates API controllers and configurations that allow us to version and manage our endpoints the way we want.

Thus, each model has its own files and its own database table, which is an excellent thing when it comes to respecting the ubiquitous language.

julien@computer:~/Projects/beerdex$ git status -u
Sur la branche master
Fichiers non suivis:
  (utilisez "git add <fichier>..." pour inclure dans ce qui sera validé)

	backend/api/beer/config/routes.json
	backend/api/beer/controllers/beer.js
	backend/api/beer/models/beer.js
	backend/api/beer/models/beer.settings.json
	backend/api/beer/services/beer.js

Now, we can add our first beers to the collection we have created.

Add Beer to collection

Every model that is created in strapiJS is exposed through a REST route that has the same root url as the admin. So, our beer model is accessible at "http://localhost:1337/beers".

What if I try to retrieve the beer collection from the API now that beers have been added?

julien@computer:~/Projects/beerdex$ curl http://localhost:1337/beers
{"statusCode":403,"error":"Forbidden","message":"Forbidden"}

I'm getting a 403 because I haven't declared an authorization for the model yet. To do this, just go to the roles and authorizations settings.

By default, there are 2 roles:

  • Authenticated => Authenticated user (in User collection)
  • Public => Everybody

So, I will give read access rights to everyone. That is, on the count, find and findone actions.

Now that our endpoint is readable, all we have to do is relaunch our request.

julien@computer:~/Projects/beerdex$ curl http://localhost:1337/beers | json_pp
[
   {
      "created_at" : "2020-03-24T14:07:27.475Z",
      "image" : [
         {
            "size" : 65.66,
            "sha256" : "Ie4LlZ-kEeedbRTsu76JyFf0-G-08NfUgIHM_A9xcx8",
            "id" : 2,
            "provider" : "local",
            "url" : "/uploads/b62ae07b7da14f01b0d97893e97c38fd.png",
            "mime" : "image/png",
            "hash" : "b62ae07b7da14f01b0d97893e97c38fd",
            "updated_at" : "2020-03-24T14:07:27.516Z",
            "ext" : ".png",
            "provider_metadata" : null,
            "created_at" : "2020-03-24T14:07:27.516Z",
            "name" : "tile009.png"
         }
      ],
      "id" : 2,
      "updated_at" : "2020-03-24T14:07:27.475Z",
      "name" : "Corona"
   },
   {
      "name" : "Heineken",
      "image" : [
         {
            "provider" : "local",
            "id" : 3,
            "sha256" : "bpZf0x4NjarbCN1rwKCrnRPotGf3vWWEv1Dn8uw8cvM",
            "size" : 45.52,
            "name" : "tile008.png",
            "ext" : ".png",
            "updated_at" : "2020-03-24T14:07:40.866Z",
            "provider_metadata" : null,
            "created_at" : "2020-03-24T14:07:40.866Z",
            "hash" : "1926fe590e534f4aaddd6dbb7f6fc18e",
            "url" : "/uploads/1926fe590e534f4aaddd6dbb7f6fc18e.png",
            "mime" : "image/png"
         }
      ],
      "created_at" : "2020-03-24T14:07:40.838Z",
      "updated_at" : "2020-03-24T14:07:40.838Z",
      "id" : 3
   }
]

Here we are, our data is here! As you can see, the usual updated_at and created_at fields are automatically added to our template.

Not even 5 minutes and our database and api endpoint are ready to use. Now, let's create a frontend to display this data.

Client-Side API Usage

I don't want to spend too much time on the frontend, so I'm going to initialize an application with NextJS for the structure, ant.design for the components and SWR for data fetching and caching.

cd frontend && yarn init
yarn add next react react-dom antd swr

Once the NextJS scripts added in package.json and the node service added in docker-compose.yml with the right environment variables, here is the result:

{
  "name": "frontend",
  "version": "1.0.0",
  "main": `index.js`,
  "license": "MIT",
+ "scripts": {
+   "dev": "next",
+   "build": "next build",
+   "start": "next start"
+ },
  "dependencies": {
    "antd": "^4.0.4",
    "next": "^9.3.1",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "swr": "0.2.3"
  }
}
version: "3"
services:
  backend:
    image: strapi/strapi
    volumes:
      - ./backend:/srv/app
    ports:
      - "1337:1337"

+   frontend:
+     image: node:12.13.0-stretch
+     volumes:
+       - .:/app
+     environment:
+       - API_BASEURL=http://localhost:1337
+     working_dir: "/app/frontend"
+     command: yarn dev
+     ports:
+       - 3000:3000
+     depends_on:
+       - backend
// next.config.js
module.exports = {
    env: {
        API_BASEURL: process.env.API_BASEURL,
    },
};

Now I just have to create our first page which basically lists our beers in pages/index.js.

import React, { useState, useEffect } from "react";
import { withRouter } from "next/router";
import useSWR from "swr";

function HomePage({ router }) {
   const { data: beers } = useSWR(
    `${process.env.API_BASEURL}/beers${
      router.query.slug ? `?category.slug=${router.query.slug}` : ""
    }`
  );

  return (
    <ul>
      {beers.map(beer => <li>{beer.name}</li>)}
    </ul>
  );
}

export default HomePage;

I restart the server with make stop start. It works, but the results leave something to be desired. Here's what it looks like after a little paint job from the basic layout of ant.design (https://ant.design/components/layout/).

Beer List

Great, we've got our beer list, now how about we categorize them via the navbar tabs? So, I repeat the same process as when I created the content type and add a new "category" that has a "name" field and a dynamically generated "slug" field (similar to the one in Sanity).

Add category relation

Now that our category exists, it is possible to add a one-to-many relationship to our beers. The relationship editor is really nice!

You can add a relation to a user role or permission, too. That's so cool if you want to filter on user rights.

So, after creating some categories, linking them using the strapi admin, and adding permissions, here is the api call result on "/beers"

[
    {
        "id": 2,
        "name": "Corona",
+       "category": {
+           "id": 1,
+           "name": "Ale",
+           "slug": "ale",
+           "created_at": "2020-04-01T08:21:42.830Z",
+           "updated_at": "2020-04-01T08:23:42.390Z"
+       },
        "created_at": "2020-03-24T14:07:27.475Z",
        "updated_at": "2020-04-01T08:37:35.597Z",
        "image": [/* ... */]
    },
    {
        "id": 3,
        "name": "Heineken",
+       "category": {
+           "id": 2,
+           "name": "Lager",
+           "slug": "lager",
+           "created_at": "2020-04-01T08:23:57.503Z",
+           "updated_at": "2020-04-01T08:24:02.102Z"
+       },
        "created_at": "2020-03-24T14:07:40.838Z",
        "updated_at": "2020-04-01T08:37:38.567Z",
        "image": [/* ... */]
    }
]

We can also access them on "/categories".

[
    {
        "id": 1,
        "name": "Ale",
        "slug": "ale",
        "created_at": "2020-04-01T08:21:42.830Z",
        "updated_at": "2020-04-01T08:59:45.084Z"
    },
    {
        "id": 2,
        "name": "Lager",
        "slug": "lager",
        "created_at": "2020-04-01T08:23:57.503Z",
        "updated_at": "2020-04-01T08:59:48.258Z"
    }
]

I can now list the categories in the navbar. To do this, I rename index.js to [slug].js in order to access the slug parameter from the NextJS router.

I also created a new index.js file with a simple export of [slug].js as default. So, this way I can have a beer listing without the slug parameter.

Beer Categories

We can list and filter beers by category, but we can't delete or create them for the moment. And I don't want unauthenticated users to be able to do that.

So, we'll change the permissions on the "beers" endpoint and add write, update and delete permissions for the "authenticated" role. The form will only appear for them.

Add Mutation Permissions StrapiJS

Now all we have to do is create a user with this role and make a call on the login route to get a JWT.

julien@computer:~/Projects/beerdex$ http POST http://localhost:1337/auth/local identifier=julien password=password --print b
{
    "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNTg1NzQ3MTI4LCJleHAiOjE1ODgzMzkxMjh9.FW2z9dSEYpJss3FZf0fqOX746uZPyJiswmhBlr8J3Oc",
    "user": {
        "blocked": false,
        "confirmed": true,
        "created_at": "2020-04-01T13:05:20.304Z",
        "email": "julien@marmelab.com",
        "id": 1,
        "provider": "local",
        "role": {
            "description": "Default role given to authenticated user.",
            "id": 1,
            "name": "Authenticated",
            "type": "authenticated"
        },
        "updated_at": "2020-04-01T13:07:07.394Z",
        "username": "julien"
    }
}

We are now ready to set up a login form. Here is the result after applying the build-in ant.design form system and a custom token persistence using useLocalStorage and React context.

User Login

Now that our user is logged in, I will give the possibility to modify the name of the beers directly from the list.

To do this, a PUT request is launched from the onBlur event of each beer card input. Once the response is received, an update of the list is triggered by SWR's mutate function.

// ./frontend/Components/BeerCard.js
export const BeerCard = ({ beer, onUpdate }) => {
  const auth = useAuth();
  
  const handleTitleBlur = async () => {
    await fetch(`${process.env.API_BASEURL}/beers/${beer.id}`, {
      method: "PUT",
      body: JSON.stringify({ name: title }),
      headers: {
        Authorization: `Bearer ${auth.token.jwt}`,
      },
    });

    onUpdate();
  };

  return (
    <Card>
        {/* .... */}
       <input
          onBlur={handleTitleBlur}
          type="text"
        />
    </Card>
  );
};
// ./frontend/pages/[slug].js
const HomePage = ({ router }) => {
-  const { data: beers } = useSWR(
+  const { data: beers, mutate } = useSWR(
    `${process.env.API_BASEURL}/beers${
      router.query.slug ? `?category.slug=${router.query.slug}` : ""
    }`
  );

  return (
    <Layout className="layout">
      <Header style={{ overflow: "hidden" }}>
        <div className="logo" />
        <NavBar />
      </Header>
      <Content className="content">
        <div className="innercontent">
          <Row gutter={[16, 16]}>
            {(beers || []).map((beer) => (
              <Col key={beer.id} xs={12} sm={8} md={8} xl={4}>
-               <BeerCard beer={beer} />
+               <BeerCard beer={beer} onUpdate={mutate} />
              </Col>
            ))}
          </Row>
        </div>
      </Content>
    </Layout>
  );
};

We are now able to change the name of a beer from the list.

Update Beer Name

As you might expect, it is also possible to create or delete items from a collection using POST or DELETE as on any REST API.

I won't give an example to avoid making this article too long, but now you know what you have to do (apart from the apéritif of course ^_-)

The final result is available on GitHub: strapi-beerdex. Feel free to comment and improve!

Conclusion

I've been looking for a long time for a tool that would allow me to have both a good back-office and an effortless REST API for my single page apps.

I never thought I would have found my happiness in an open-source Headless CMS, self-hosted and even French! Although I created a small application with it, I only scratched the surface and many other features remain to be discovered such as GraphQL, file upload, webhooks, or the registration system.

The only outstanding issue is migration and database management. How to properly manage database migrations? Unless I'm wrong, currently all database changes are made when the NodeJS server is started. Though I have not found much information on this subject, I have no doubt that the team in charge of the project will be able to find a viable solution in the long term.

I tend to be more nuanced about the tools I test, but I was so bluffed by Strapi that I have no other things to complain about. If you are looking for a Headless CMS and want to keep your hands on your API, StrapiJS is probably the right choice!

Did you like this article? Share it!