CMS for 0 rubles
Hello everyone! My name is Alexander, I am a frontend developer at KTS. Today I will tell you about Strapi CMS, analyze its use cases using specific examples and share ways to simplify working with it.
Let me start with a little background. Our company often develops projects where it is necessary to regularly update and customize the site content. For such cases, we use various CMS systems that allow you to work with content using a graphical interface.
When using such a CMS system, you want it to be responsible exclusively for content and not impose any restrictions on the UI, so when choosing, we focused on Headless CMS. Headless CMS are systems that provide content administration functionality (admin panel) and generate an API to which any client can be connected (web application, mobile application, etc.). Since Headless CMS is just an API, they do not limit the client in any way and do not affect the implementation of other backend services.
We chose Strapi because it is one of the most popular solutions today. The system is open source, and although it has a paid version, the free functionality is quite sufficient for work. We use the Community Edition (62k+ stars and almost 7k forks on github). In this article, I will share our experience working with Strapi and clearly describe how it can be used to solve practical problems.
Table of contents:
What Strapi Can Do
With Strapi, a developer can define collections of data that will be accessible via API from the CMS, and also give them a specific structure. Strapi automatically generates the entire boilerplate for performing CRUD operations on these collections of data, namely:
creates collections in the database;
generates a REST API (or GraphQL) for these collections;
allows you to configure restrictions on access to endpoints;
makes it possible to change logic at any level using programming (customize or add new endpoints, change database calls, etc.).
Once developers have set up the data structure, CMS users (such as content managers) can populate collections with content. This data can then be queried via the API.
The sequence of work with Strapi can be represented in the form of the following diagram:
How to create a project on Strapi
To create a new project, you need to run the following command:
yarn create strapi-app kts-strapi-project --quickstart
You will get a project with the following structure:
The following folders will contain the key logic for working with Strapi and data collections:
src/api
– the structure will be stored here collections in the form of json files (in other words, schemas), as well as generated js files with the logic code of these collections;src/components
– this is where the schemes are stored components. These are utility entities and, unlike collections, no API is generated for them. A component can be connected as a field of another component or collection. When you query the API for a collection, you will get the components associated with it;src/extensions
– are stored here extensionsadded for ease of use with CMS. You can write your own extension or connect it from Strapi Extension Store.
To run the project, run the following command:
yarn develop
The system will prompt you to register. After registration, you will be able to log in using the created credentials:
Strapi Operating Modes
Strapi has 2 modes: Content-Type Builder And Content Manager.
Content-Type Builder is only available in dev mode. Dev mode is a locally running Strapi server. On it, developers configure the structure of collections, determine what properties and attributes (in other words, fields) they have. After saving the collection, code generation of json schemas, migrations for the database and the server boilerplate occurs.
Content Manager is available in both dev and prod modes. Prod mode is a mode in which the structure of collections cannot be changed, it exists for filling collections with data.
How to create collections in Strapi
For clarity, let's consider the work of Strapi using the example of a personal account project for a university student. First, we will create collections with a minimum set of properties, and then supplement them as we get acquainted with the capabilities of Strapi. First, we will introduce some collections that will be used on our site.
Collection “Student” with the following fields:
Collection “Specialty” with the following fields:
name of the specialty;
specialty code;
duration of study (in years).
Types of data collections
There are three types of collections in Strapi – Single Type, Collection Types And Component.
Each of them is suitable for a specific purpose.
Single Type is suitable if you are sure that the given collection is stored in a single copy. For example, you will probably need the following single type collections:
Documents – a collection of documents in the application with the following fields:
user_agreement;
privacy_policy;
other;
Main Page – collection for the main page of the application;
Header – a collection for managing main menu items;
Footer.
Collection Types suitable if the collection is a list of data.
For example, these could be:
Components – these are auxiliary entities. They cannot be requested from the API as a separate list, but can be included as a field for a Single Type, Collection Type, or another component. Examples of Components can be:
a link that consists of the text for the link and its URL;
a card that consists of a title, an image and a description;
a coordinate that consists of latitude and longitude.
Data types
Let's create two collections of the Collections Types type: “Student” and “Specialty”.
When creating, you need to specify Display Name (display name of the collection in Strapi), and API ID in singular and plural – they will be used further to perform CRUD operations with collections.
When creating a collection, you must assign a type to each property:
Text – line;
Rich text (blocks) – text with formatting (can be made bold, italic, etc.);
Number – number;
Date – date in date, datetime or time format;
Media – image or video in json format, stores a link to the file in S3 storage;
Relation – the “relationship” data type. It is needed to set relationships between collections (but not components). For example, each specialty has many students, so a student will have a Relation field with a link to the Specialty with a “one-to-many” relationship type;
Boolean – logical data type;
JSON – data in JSON format;
Email – matches the string, but is validated for the email address format within Strapi;
Password – can't be queried from the API, but can be used if you customize the queries. We haven't found a use for this data type yet, since storing passwords in the DB isn't a good idea;
Enumeration – selection from a limited list of text values;
UID – it comes to the client as a string, but on the Strapi side a check for uniqueness is performed;
Component – a reusable component from the Components collection;
Dynamic Zone: Let's say we have an “Article” entity that consists of a title and content. Content, in turn, is an array of arbitrary components from a certain set (for example, an image, text, video, and poll). Dynamic Zone allows you to create such dynamic lists of components.
Example of creating a collection
Let's create a “Student” collection with the structure we designed above – add the fields “name”, “surname” and “date of birth”:
Next, we will create the “Specialty” collection in a similar way:
Next, we need to add a relationship for the Student and Specialty collections so that we can request all students from a specific specialty from the API or find out the specialty in which a particular student is studying. To do this, we will add a specialty field with the Relation type to the Student collection. Each specialty has many students, so the relationship will be one-to-many.
First, let's add a “specialty” field of type Relation:
For the Student collection, we will add the “photo” field with the Media type. It is worth noting that when adding a Media type field, you can set restrictions on data formats. By default, there are any files. You can limit: only images, only videos, or only pdf:
Let's assume that each specialty has a link to a website with its detailed description. Let's add the “link” field to the specialty collection. A link is a combination of a title and an address. As discussed earlier, a link needs to be made a component because it does not exist on its own and does not need to be retrieved from the API. We also need to add the “specialty_link” field to the “Specialty” collection. To do this, we will create a corresponding “Link” component in advance, which will consist of a title and a heading:
Next, we reuse the created component in the “Specialty” collection:
Code generation
After saving each of the collections, the code generation of the schemes is automatic. In fact, the code is generated according to the principles of the Model-Routes-Controllers-Service architecture, only instead of Model the directory is called content-types. You can read more about this architecture Here.
The logic for getting collections is also generated, and all of this is stored in the following folders:
content-types: this is where the collection schema that comes with the API is stored;
controllers: this is where the logic is stored that processes the HTTP request when accessing the API: validation and parsing of request parameters occurs, the structure of the response from the server is configured. Controllers use services internally to access the database. Controllers, unlike services, cannot be reused;
routes: This is where the list of endpoints of this collection that are available for retrieval is stored;
services: here the logic of accessing the database is configured. Also, it is in services that some specific business logic for the application should be written: for example, sending a letter to the user's email (this is easy to do with plugins). Services are used inside controllers, and can also reuse each other.
The result is the following files:
specialty files:
student files:
It is worth noting that after creating a collection, its Singular ID and Plural ID cannot be changed from the interface – they can only be changed by editing the code in the files above, but such a change often leads to errors in migrations. If you still need to change the identifiers for a collection, the most reliable way is to delete it and re-create it.
How to work with API
Once the code is generated, you can move on to working with the API and filling the collections with content. In this section, we will continue to consider the functionality of Strapi using the example of a student's personal account project.
Plugin for autogenerating swagger
Strapi allows you to install plugins that make it easier to configure and test collections. One such plugin, strapi documentation, allows you to get an auto-generated swagger. To install it, run the following command:
yarn strapi install documentation
After installation, documentation will be available at:
http://localhost:{PORT}/documentation/v1.0.0.
Adding data to a collection
Using the Content Manager mode, we will create several instances of the Student collection and fill them with data. It is important to note here that by default, an instance of the collection is created in the state Draft – this means that it will not be accessible via the API. In order to open access to the created instance, you need to go to its editing mode and click Publish.
OnSetting up access
By default, Strapi makes all collections private – you can only get them by sending a generated token in the header. To make a collection public, you need to open the “Roles” section in the settings. It will be located at:
http://localhost:1338/admin/settings/users-permissions/roles.
In this section you can choose which collections will be accessible to authorized and unauthorized users.
Public Requests
Let's specify what types of operations an unauthorized user can perform with the Student collection. Reading the collection and a single instance is enough for us, so we'll check the find and findOne flags.
Private requests
If we want some API requests to be available only to authorized users, we should create an API Token, which will have to be sent with each request. This can be done at:
http://localhost:1338/admin/settings/api-tokens/create.
The value of the generated token must be passed in the header for each request. Authorization
in swagger this is done by pressing a button Authorize):
Request parameters
Let's look at the query parameters for getting the Student collection. They fall into two categories.
The first category is those parameters that are related exclusively to pagination.
The second category is parameters that help you customize which fields you want to query from collection instances and their order.
I suggest taking a closer look at the parameters of this category:
sort – the value by which the collection should be sorted. Accepts the field name and sort direction (asc/desc) as input. Multiple sorting is possible;
fields – a list of primitive fields to be retrieved from the API. By default, all primitive fields are returned, which include text, enumeration, rich_text, email, password, date, number, boolean, and JSON. Fields allows you to describe only those fields that need to be retrieved;
populate: Relation, Media, Dynamic Zone and Component are not primitive fields, because querying them requires additional queries to the DB or using a join. Therefore, by default, Strapi sends the id of this relation instead of the relation. To query fields of nested entities, these fields must be listed in populate;
filters – rules for filtering the collection. Can take values
$gte
,$equal
and others.
You can read more about the parameters in Strapi documentation.
Example request
Let's make a request for a list of students:
http://localhost:1338/api/students
And we get the following answer:
Reply to request
{
"data": [
{
"id": 1,
"attributes": {
"name": "Арсений",
"surname": "Ковалев",
"birthday_date": "2004-07-14T20:00:00.000Z",
"createdAt": "2024-07-17T09:16:31.088Z",
"updatedAt": "2024-07-21T15:41:59.989Z",
"publishedAt": "2024-07-17T15:43:49.701Z"
}
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}
Please note: our collection instance is wrapped in a structure of the form { id, attributes }
. Strapi does this with each instance of the collection, as well as with the nested entities we talked about above.
You can also notice that in the response to the request we did not receive the fields speciality
And image
. This is because they are nested entities. As I mentioned earlier, Strapi does not add them to the response by default.
To get information about a student's specialty and photo from the API, you need to add a parameter populate
and specify in it what specific properties you want to obtain. Here you can read about how to correctly describe populate
.
Let's add information about the fields we need to the query, and we'll get a query like this:
http://localhost:1338/api/students?populate=speciality,photo
With parameter populate
The answer has changed to include fields speciality
And photo
.
New response to request
{
"data": [
{
"id": 1,
"attributes": {
"name": "Арсений",
"surname": "Ковалев",
"birthday_date": "2004-07-14T20:00:00.000Z",
"createdAt": "2024-07-17T09:16:31.088Z",
"updatedAt": "2024-07-21T20:27:58.544Z",
"publishedAt": "2024-07-17T15:43:49.701Z",
"speciality": {
"data": {
"id": 1,
"attributes": {
"createdAt": "2024-07-17T09:17:03.596Z",
"updatedAt": "2024-07-17T15:17:34.613Z",
"publishedAt": "2024-07-17T14:59:42.231Z",
"name": "Прикладная математика и информатика",
"code": "01.03.02",
"duration": 4
}
}
},
"photo": {
"data": {
"id": 1,
"attributes": {
"name": "2024-06-30 12.45.19.jpg",
"alternativeText": null,
"caption": null,
"width": 960,
"height": 1280,
"formats": {
"thumbnail": {
"name": "thumbnail_2024-06-30 12.45.19.jpg",
"hash": "thumbnail_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 117,
"height": 156,
"size": 4.82,
"sizeInBytes": 4815,
"url": "/uploads/thumbnail_2024_06_30_12_45_19_9e691ba632.jpg"
},
"small": {
"name": "small_2024-06-30 12.45.19.jpg",
"hash": "small_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 375,
"height": 500,
"size": 32.88,
"sizeInBytes": 32882,
"url": "/uploads/small_2024_06_30_12_45_19_9e691ba632.jpg"
},
"medium": {
"name": "medium_2024-06-30 12.45.19.jpg",
"hash": "medium_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 563,
"height": 750,
"size": 68.55,
"sizeInBytes": 68547,
"url": "/uploads/medium_2024_06_30_12_45_19_9e691ba632.jpg"
},
"large": {
"name": "large_2024-06-30 12.45.19.jpg",
"hash": "large_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 750,
"height": 1000,
"size": 112.06,
"sizeInBytes": 112055,
"url": "/uploads/large_2024_06_30_12_45_19_9e691ba632.jpg"
}
},
"hash": "2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"size": 156.6,
"url": "/uploads/2024_06_30_12_45_19_9e691ba632.jpg",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2024-07-21T20:27:55.626Z",
"updatedAt": "2024-07-21T20:27:55.626Z"
}
}
}
}
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}
How can you customize Strapi?
Let's consider the following situation. Each specialty has a detailed page on the university website, which has approximately the following URL:
https://project-example.ru/speciality/{serial_id}
serial_id is an identifier that is automatically assigned to each instance of the collection when it is created. Strapi uses it to obtain information about a single instance of the collection. For example, you can obtain detailed information about a specialty using the following command:
GET https://strapi-example.ru/api/speciality/<id>
In order to improve SEO, each detailed page of a specialty needs to have a slug (a readable URL) of approximately the following format:
To do this, Strapi allows you to rewrite the logic of the controller and routes that are responsible for the rules by which the request to the API will be executed. To do this, in the Content-Type Builder mode, we will add a slug field of the UID type to the structure of the “Specialty” collection. Now the slug of each specialty will be unique:
Then, in Content Manager mode, we assign each instance of the collection its own slug and publish the entire collection:
Now we need to rewrite the logic of the controller so that we can get the required instance of the collection by its slug
To do this, you will have to perform the following transformations for the “Specialty” collection:
Add custom route
/api/specialties/get-by-slug/{slug}
.Add to file
services
functionfindOneBySlug
so that the SQL query is processed correctly at the Strapi level.Improve the logic of the controller so that it correctly retrieves from query parameters
slug
Andpopulate
and passed them to the functionfindOneBySlug
.
First, let's add the utility kts-strapi-project/src/utils/extendCoreRouter.js
to extend the logic of the default router:
const extendCoreRouter = (innerRouter, extraRoutes = []) => {
let routes;
return {
get prefix() {
return innerRouter.prefix;
},
get routes() {
if (!routes) routes = [...extraRoutes, ...innerRouter.routes];
return routes;
},
};
};
module.exports = { extendCoreRouter };
Then we will finalize the file kts-strapi-project/src/api/speciality/services/speciality.js
with a query to the database:
Was
'use strict';
/**
* speciality service
*/
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::speciality.speciality');
It became
"use strict";
const { createCoreService } = require("@strapi/strapi").factories;
module.exports = createCoreService(
"api::speciality.speciality",
({ strapi }) => ({
async findOneBySlug(slug, { populate }) {
return strapi.db.query("api::speciality.speciality").findOne({
where: {
slug: slug,
},
populate,
});
},
})
);
We will do the same with other files.
kts-strapi-project/src/api/speciality/controllers/speciality.js
:
Was
'use strict';
/**
* speciality controller
*/
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::speciality.speciality');
It became
"use strict";
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController(
"api::speciality.speciality",
({ strapi }) => ({
async findOneBySlug(ctx) {
const { slug } = ctx.params;
const sanitizedQuery = await this.sanitizeQuery(ctx);
const result = await strapi
.service("api::speciality.speciality")
.findOneBySlug(slug, {
populate: sanitizedQuery.populate,
});
const sanitizedResults = await this.sanitizeOutput(result, ctx);
return this.transformResponse(sanitizedResults);
},
})
);
kts-strapi-project/src/api/speciality/routes/speciality.js:
Was
'use strict';
/**
* speciality router
*/
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::speciality.speciality');
It became
"use strict";
const { extendCoreRouter } = require("../../../utils/extendCoreRouter");
const { createCoreRouter } = require("@strapi/strapi").factories;
const defaultRouter = createCoreRouter("api::speciality.speciality");
module.exports = extendCoreRouter(defaultRouter, [
{
method: "GET",
path: "/specialties/get-by-slug/:slug",
handler: "speciality.findOneBySlug",
config: {
auth: false,
},
},
]);
So we added a custom endpoint /api/specialties/get-by-slug/{slug}
.
The examples above use the function sanitizeQuery
it is used to clean the request parameters from errors and unsafe values. You can read more about sanitize Here.
Now let's query some instance from the “Specialty” collection using slug. Let's make the following query:
http://localhost:1338/api/specialties/get-by-slug/prikladnaya-matematika-i-informatika
And we get the following answer:
{
"data": {
"id": 1,
"attributes": {
"createdAt": "2024-07-17T09:17:03.596Z",
"updatedAt": "2024-07-23T17:38:09.042Z",
"publishedAt": "2024-07-17T14:59:42.231Z",
"name": "Прикладная математика и информатика",
"code": "01.03.02",
"duration": 4,
"slug": "prikladnaya-matematika-i-informatika"
}
},
"meta": {
}
}
Now we can easily get collection instances not by serial_id, but by slug.
However, it is important to note that Swagger will not be able to pull up the types of the parameters received, despite the change in the receiving logic. Therefore, it will not be possible to make a request using slug via Swagger:
In this case, it will be possible to make a request, for example, via postman:
Conclusion
In this article, we looked at Strapi in general terms and determined what it is used for. We created several collections and got them using the API and added a custom route to get collection instances using slug. However, the functionality of the system, of course, does not end there.
If you're eager to dive deeper, stay tuned. In the next article, we'll dive deeper into Strapi and talk about:
how to handle complex data types (rich text and dynamic zone);
how to sort collections by popularity;
how we type and validate collections on the client using Typescript and zod;
how to connect postgres, s3, minio;
what data should be stored in Strapi and what in the admin panel;
What's expected in the new version of Strapi 5.
And to brighten up the wait, I suggest you read other materials on our blog, no less useful for front-end developers: