Building A Web Application In 15 Minutes Using StrapiJS And NextJS
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, French And Full Featured
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 in │ 8696 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.
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.
Then I add the few necessary fields and click save.
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.
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/).
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).
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.
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.
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.
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.
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!