Introduction
The Scene: You're migrating from Strapi 4 to Strapi 5 to get access to a ton of new features such as improved content localization, draft-and-publish enhancements, content history, and more.
You back up your database, make sure your code is all committed in git, and run npx @strapi/upgrade major
to automatically migrate your project code for you. Everything goes smoothly, so you jump right in and start up your new Strapi 5 server. It works!
After exploring for a while, you notice something is off — quite literally. You go back and look at the changes made in your migrated codebase and you see it:
Your database lifecycle hooks have been commented out! And there's an ominous warning that they were disabled by default because they will not work as expected in Strapi 5. But what does that mean? What happened? Why would Strapi do that to your poor lifecycle hooks? And most importantly, what do you do next?
Why?!
Lifecycle hooks are tied to specific content types and trigger actions in your code from database activity. They were used to perform additional operations when content changes happened, such as sending email, calculating attribute values, or programmatically creating additional content. It worked well, because each piece of content was directly correlated with one database entry.
In Strapi 5, with the introduction of the document service to support draft-and-publish, content history, improved handling of multi-locale content and more, the actions performed trigger more complex database activity than before. For instance, creating a new published document will result in afterCreate
and beforeCreate
hooks being called twice. That's because published versions are immutable — essentially permanent history written in stone — and a draft is also kept for edits to be made to your content. Trying to use a database lifecycle hook to figure out and filter what is being done at a higher level could quickly become a nightmare scenario.
Introducing ✨ Document Service Middleware ✨ — a new high-level approach to focus on what truly matters: the actual events in your Strapi project, rather than the database activity under the hood.
Document service middlewares can be used to extend the document service methods with new functionality or modify their existing functionality. They are loaded in your Strapi register()
function and can affect multiple content types and actions with the same code. This makes them more flexible and lets you do more with less code.
Let's take a quick view of the differences between lifecycles and middleware.
DB Lifecycles | Document Service Middleware |
---|---|
Exist in both Strapi 4 and Strapi 5 | New in Strapi 5 |
directly attached to a specific content-type | global; can be filtered by content-type |
directly attached to a specific database query | global; can be filtered by the actions being performed by the document Service |
loaded from files stored in each content-api directory | loaded in register() |
before and after hooks to take action before or after a database query | Can trigger actions before and after a document service method is called, but can also modify the incoming parameters/data and the result returned by the method |
Lifecycles vs. Middleware: Side-by-Side Examples
To get a basic idea of how you might convert a lifecycle to a middleware, here's an example of a Strapi 4 lifecycle hook to generate a slug for an article if one wasn't provided
1// content-api/article/lifecycles.ts
2module.exports = {
3 lifecycles: {
4 async beforeCreate(event) {
5 const { data } = event.params;
6 data.slug = data.slug || slugify(data.title);
7 },
8 },
9};
This approach works well for a single content type like article, but it's not reusable across other types, such as blog or news. You have to copy and paste your file for each content type when they might all have a slug field that needs to be added. Additionally, extending this to handle locales or drafts would add significant complexity to the lifecycle logic. And finally, if you want to get a global view of all of your existing hooks, you have to look through every content type directory to find them.
Let's look at how that could be refactored using document service middleware in Strapi 5.
Document service middleware receive a context
object and a next
function.
The context action corresponds with the document service method that is actually being called (such as create
, update
, delete
, publish
, and unpublish
).
It's important to note that the context object is a reference and any changes to it will affect other middleware called after this one. So all we have to do it modify it with whatever changes we want.
1// index.ts
2
3// ...your other code
4
5register({ strapi }) {
6 strapi.documents.use(async (context, next) => {
7 // target the 'create' action on articles
8 if (context.uid == 'api::article.article' && context.action == 'create') {
9 context.params.data.slug = context.params.data.slug || slugify(context.params.data.title);
10 }
11
12 // always return next()
13 return next();
14 });
15}
16
17// ...your other code
You can read about context in the docs.
"I could have figured that out on my own" you say. Ok, fine, let's take a look at a more complex, real-world scenario, and then build it.
Let's say you're building a website using Strapi as the back-end. On the site, you will have articles, pages, and products.
- You want to ensure articles, pages, and products always have generated slugs that are different per-locale when they aren't manually set
- Your authors are very tired and want to automatically replace "btw" with "by the way" so they don't have to type as much
- Your authors want to get an email as soon as their content is published
Where to begin?
We already generated slugs in our previous example, so let's extend it to include the other content types and actions. To keep it clean, we'll create some arrays of UIDs and actions.
For the slug, we have access to the locale of the updated document, so we'll append that as well.
1// index.ts
2// ...
3register({ strapi }) {
4 const pageTypes = ['api::article.article', 'api::page.page', 'api::product.product'];
5 const pageActions = ['update', 'create'];
6
7 strapi.documents.use(async (context, next) => {
8 if (pageTypes.includes(context.uid) && pageActions.includes(context.action)) {
9 const { data, locale } = context.params;
10 context.params.data.slug = data.slug || slugify(data.title + '-' + locale);
11 }
12
13 return next();
14 });
15}
16// ...
This demonstrates how middleware is better suited for Strapi 5's more complex document-handling scenarios. The middleware can easily adapt to new requirements, such as handling locale-based variations or additional content types without needing significant restructuring.
We can help out our tired authors with the text replacement in the same way
1// index.ts
2// ...
3if (pageTypes.includes(context.uid) && pageActions.includes(context.action)) {
4 const { data, locale } = context.params;
5 context.params.data.slug = data.slug || slugify(data.title + "-" + locale);
6 context.params.data.content = data.content.replace("btw", "by the way");
7}
8// ...
Now let's send that email. What if you just throw in a notifyAuthorByEmail(data)
like all the others?
1// index.ts
2// ...
3register({ strapi }) {
4 const pageTypes = ['api::article.article', 'api::page.page', 'api::product.product'];
5 const pageActions = ['create', 'update'];
6 const sendEmailActions = ['publish'];
7
8 strapi.documents.use(async (context, next) => {
9 if (pageTypes.includes(context.uid) && pageActions.includes(context.action)) {
10 const { data, locale } = context.params;
11 context.params.data.slug = data.slug || slugify(data.title + "-" + locale);
12 context.params.data.content = data.content.replace('btw', 'by the way');
13 }
14
15 if(pageTypes.includes(context.uid) && sendEmailActions.includes(context.action)) {
16 await notifyAuthorByEmail(data.author, data.title);
17 }
18
19 return next();
20 });
21}
22// ...
You try it out, and it mostly works, but you immediately notice a few problems:
- the email gets sent even when the document fails to publish due to a validation check you added in a later middleware
- you have a plugin that makes titles ALL CAPS, but your email is sent before the changes are applied
But you know exactly why that is happening, because you're wise and you read this article. You know that the middleware gets run whenever the corresponding document service method is called, not when it succeeds. You also know that you have to wait for the result of all the other middleware that come after yours to run to know what the final document will look like.
1// index.ts
2strapi.documents.use(async (context, next) => {
3 if (pageTypes.includes(context.uid) && pageActions.includes(context.action)) {
4 const { data, locale } = context.params;
5 context.params.data.slug = data.slug || slugify(data.title + "-" + locale);
6 context.params.data.content = data.content.replace("btw", "by the way");
7 }
8
9 // let the other middleware finish and allow the document service to return
10 const result = await next();
11
12 if (
13 pageTypes.includes(context.uid) &&
14 sendEmailActions.includes(context.action)
15 ) {
16 // use the data from the final result rather than the params passed in
17 await notifyAuthorByEmail(result.author, result.title);
18 }
19
20 // remember we still need to return the document!
21 return result;
22});
There we go. Much better. Now we're waiting for all the other middleware to run and for the actual document service method to be called (creating it in the database), and are sure that any modifications that they make or errors that they throw will be done before you send your email.
Better practices
Ok, but it's a bit ugly. You have other register
code already, and now you have this extra code that we know is going to grow over time. What to do?
Well, this is javascript after all, you have unlimited options, many of them even worse. But here at Strapi, we recommend storing your middleware in their own file or files. For example, we could simply create a file called document-service-middlewares.ts
and import that in our register script:
1// document-service-middlewares.ts
2const pageTypes = ['api::article.article', 'api::page.page', 'api::product.product'];
3const pageActions = ['create', 'update'];
4const sendEmailActions = ['publish'];
5
6export const registerDocServiceMiddleware = ({ strapi }) => {
7 strapi.documents.use(async (context, next) => {
8 if (pageTypes.includes(context.uid) && pageActions.includes(context.action)) {
9 const { data, locale } = context.params;
10 context.params.data.slug = data.slug || slugify(data.title + "-" + locale);
11 context.params.data.content = data.content.replace('btw', 'by the way');
12 }
13
14 // let the other middleware finish and allow the document service to return
15 const result = await next();
16
17 if (pageTypes.includes(context.uid) && sendEmailActions.includes(context.action)) {
18 // use the data from the final result rather than the params passed in
19 await notifyAuthorByEmail(result.author, result.title);
20 }
21
22 // remember we still need to return the document!
23 return result;
24 });
25};
26
27// index.ts
28// ...
29import { registerDocServiceMiddleware } from './document-service-middlewares'
30register({ strapi }) {
31 // register middleware as early as possible
32 registerDocServiceMiddleware({ strapi });
33
34 // all of your other register code
35}
36// ...
That keeps your register method clean, and makes it clear what is happening. As your project grows and you add more middleware, you can start putting them in their own files and call them from your registerDocumentServiceMiddlewares
.
Let's Look at an Example Project with some extra examples to get some extra practice.
Thanks Ben, Paul here, I wanted to try this out for myself based on what I learned above, so I built a simple project that I want to share with you and talk through the code.
Setting up the project
Navigate to the repository here and clone it by running the following command:
git clone https://github.com/PaulBratslavsky/strapi-document-service-middleware-example.git
cd strapi-document-service-middleware-example
Once in the project, install the dependencies by running the following command:
yarn install
Now, that our project is installed, let's copy our .env.example
file to .env
and fill in the required fields.
touch .env
cp .env.example .env
You should now have a .env
file that looks like this:
1# Server
2HOST=0.0.0.0
3PORT=1337
4
5# Secrets
6APP_KEYS=hRpxdHtQdUuY8Gz53VH64A==,kmR+WV12RLcZexnbF4117A==,sWcped1l1hURmFR1KSb3tQ==,zmoJdb2gpRJIXT2aDasWtA==
7API_TOKEN_SALT=MZp8WxMjDyX3VqeURvSdLg==
8ADMIN_JWT_SECRET=oQ/HjxrPx3hhLcGqT4WMCg==
9TRANSFER_TOKEN_SALT=lwnuYtZk1CYCbfdZQfXxfQ==
10
11# Database
12DATABASE_CLIENT=sqlite
13DATABASE_HOST=
14DATABASE_PORT=
15DATABASE_NAME=
16DATABASE_USERNAME=
17DATABASE_PASSWORD=
18DATABASE_SSL=false
19DATABASE_FILENAME=.tmp/data.db
20JWT_SECRET=FgSX46RMuxAzHIswMsHdXQ==
Now, let's seed our database with example data. You can do that by running the following command:
yarn strapi import -f seed-data.tar.gz --force
This will import the example data into your database. The --force
flag will auto say yes to all the prompts.
Now, let's start our Strapi server by running the following command:
yarn develop
Once you are greeted with the Strapi welcome screen, you can go ahead and create your first admin user.
Once logged in, let's navigate to the Articles
section and select one of the articles to edit.
Let's update the title of the article to strapi is awesome and so are you
and save the changes.
You will see the following updates.
- The
title
field will be updated toStrapi is Awesome And So Are You
to title case. - The
slug
field will be updated automatically tostrapi-is-awesome-and-so-are-you
to lowercase and replace spaces with hyphens. - We will generate a log of the changes in the Log Collection section.
Navigate to the Log Collection section and you will see the following log:
We have another quick example, let's try it out.
Let's add UTM parameters to the slug.
Go ahead update the utm_source
and utm_campaign
and baseUrl
fields and save the changes. Click save and you wil notice the utmLink
field will be updated with the UTM parameters on save.
Which is also triggered by our middleware.
Let's take a look at the code to see how it works.
In our project let's navigate to the src/index.ts
file. We will see the following code:
1import type { Core } from '@strapi/strapi';
2import { contentMiddleware, emailNotificationMiddleware } from './utils/document-service-middlewares';
3
4
5export default {
6 /**
7 * An asynchronous register function that runs before
8 * your application is initialized.
9 *
10 * This gives you an opportunity to extend code.
11 */
12 register({ strapi }: { strapi: Core.Strapi }) {
13 const middlewares = [contentMiddleware, emailNotificationMiddleware];
14
15 middlewares.forEach((middleware) => {
16 strapi.documents.use(middleware());
17 });
18 },
19
20 /**
21 * An asynchronous bootstrap function that runs before
22 * your application gets started.
23 *
24 * This gives you an opportunity to set up your data model,
25 * run jobs, or perform some special logic.
26 */
27 bootstrap(/* { strapi }: { strapi: Core.Strapi } */) {},
28};
This is where we are injecting our middlewares via the strapi.documents.use
method.
We have two middlewares that we are importing from the src/utils/document-service-middlewares.ts
file.
contentMiddleware
- This middleware is responsible for converting the title to title case, generating a slug from the title, adding UTM parameters to the slug if they exist, and logging the changes made to the document and logging the changes.emailNotificationMiddleware
- This middleware is an example of how to send an email notification to the author when the document is published. You can see that it is triggered when the document is published.
note: for the email middleware we are just console logging the email notification. But in production app we would set up an email service and send actual emails.
Something that is outside the scope of this article.
Now let's take a look at the src/utils/document-service-middlewares.ts
file.
It is recommended to keep the middleware in their own file, just like we our doing here.
1import slugify from "slugify";
2import { toTitleCase, extractUtmParams, constructUrlWithParams, notifyAuthorByEmail, logChanges } from "./helper-functions";
3
4const pageTypes = ["api::article.article"];
5const pageActions = ["create", "update"];
6const sendEmailActions = ["publish"];
7
8
9const contentMiddleware = () => {
10 return async (context, next) => {
11 // Early return if the document type or action is not valid
12 if (!pageTypes.includes(context.uid) || !pageActions.includes(context.action)) {
13 return await next(); // Call the next middleware in the stack
14 }
15
16 const { data } = context.params;
17 const userId = data.updatedBy || data.createdBy;
18
19 // Convert the title to title case for better readability
20 context.params.data.title = toTitleCase(data.title);
21
22 // Generate a slug from the title for URL usage
23 context.params.data.slug = slugify(data.title, { lower: true });
24
25 const utmParams = extractUtmParams(data); // Extract UTM parameters from the data
26 const baseUrl = data?.baseUrl;
27
28 // Add UTM parameters to the slug if they exist
29 if (data?.utm_source || data?.utm_campaign) {
30 context.params.data.utmLink = constructUrlWithParams(
31 data.slug,
32 baseUrl,
33 utmParams
34 );
35 }
36
37
38 const result = await next(); // Call the next middleware in the stack
39
40 // Log the changes made to the document
41 await logChanges(context.params.data, context.action, userId);
42
43 return result; // Return the result of the middleware chain
44 };
45};
46
47const emailNotificationMiddleware = () => {
48 return async (context, next) => {
49 // Check if the document type and action are valid for sending email notifications
50 if (
51 pageTypes.includes(context.uid) &&
52 sendEmailActions.includes(context.action)
53 ) {
54 await notifyAuthorByEmail("test@test.com", "test title"); // Notify the author via email
55 }
56
57 return await next(); // Call the next middleware in the stack
58 };
59};
60
61export { contentMiddleware, emailNotificationMiddleware };
The document-service-middlewares.ts
file contains middleware functions that help process documents in a web application. It starts by importing helper functions like slugify
, toTitleCase
, extractUtmParams
, constructUrlWithParams
, notifyAuthorByEmail
, and logChanges
.
These functions handle tasks such as formatting titles, creating slugs, and sending notifications.
The file has two main middleware functions:
contentMiddleware
- Runs when a document is created or updated.
- Ensures the document type and action are valid.
- Formats the title in title case for better readability.
- Generates a slug from the title for URL-friendly links.
- Extracts UTM parameters and appends them to the slug if needed.
- Logs any changes made to the document.
- Calls the next middleware in the processing chain.
emailNotificationMiddleware
- Runs when a document is published.
- Sends an email notification to the author.
- Calls the next middleware in the processing chain.
Both middleware functions are exported so they can be used elsewhere in the application, keeping the code modular and reusable.
Now let's look inside the helper-functions.ts
file.
1/**
2 * Converts a given title to title case.
3 * @param title - The title to be converted.
4 * @returns The title in title case.
5 */
6
7function toTitleCase(title: string): string {
8 return title
9 .toLowerCase()
10 .split(" ")
11 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
12 .join(" ");
13}
14
15/**
16 * Extracts UTM parameters from the provided data.
17 * @param data - The data object containing UTM parameters.
18 * @returns An object containing the extracted UTM parameters.
19 */
20function extractUtmParams(data) {
21 return {
22 utm_source: data?.utm_source,
23 utm_campaign: data?.utm_campaign,
24 };
25}
26
27/**
28 * Validates the base URL.
29 * @param baseUrl - The base URL to validate.
30 */
31function validateBaseUrl(baseUrl: string) {
32 if (!baseUrl) {
33 console.error("Base URL is required");
34 throw new Error("Base URL is required");
35 }
36}
37
38/**
39 * Constructs a full URL with UTM parameters.
40 * @param slug - The original slug.
41 * @param baseUrl - The base URL to append the slug to.
42 * @param params - The UTM parameters to be added.
43 * @returns The full URL with UTM parameters.
44 */
45function constructUrlWithParams(slug: string, baseUrl: string, params: Record<string, string>): string {
46 validateBaseUrl(baseUrl);
47 const queryString = new URLSearchParams(params).toString();
48 const url = new URL(slug, baseUrl);
49 url.search = queryString;
50 return url.href;
51}
52
53/**
54 * Notifies the author via email about the title.
55 * @param author - The author's email address.
56 * @param title - The title to notify about.
57 */
58function notifyAuthorByEmail(author: string, title: string) {
59 /*
60 * Notifying author about the title.
61 * This function logs the notification to the console.
62 */
63 console.log(`Notifying author ${author} about ${title}`);
64}
65
66/**
67 * Logs changes made to a document.
68 * @param result - The result of the document changes.
69 * @param action - The action performed (create/update).
70 * @param user - The user who performed the action.
71 */
72async function logChanges(result: any, action: string, user: any) {
73 // Get the admin user
74 const adminUser = await strapi.documents("admin::user").findFirst({
75 filters: {
76 id: user,
77 },
78 });
79
80 // Create the log
81
82 const response = await strapi.documents("api::log.log").create({
83 data: {
84 action: action as "create" | "update",
85 json: JSON.stringify(result),
86 fullName: `${adminUser.firstname} ${adminUser.lastname}`,
87 email: adminUser.email,
88 },
89 });
90
91 console.log("Log: ", response);
92}
93
94export { toTitleCase, extractUtmParams, validateBaseUrl, constructUrlWithParams, notifyAuthorByEmail, logChanges };
The helper-functions.ts
file contains our functions responsible for formatting titles, handling UTM parameters, validating URLs, constructing full URLs, sending email notifications, and logging changes.
Key Functions:
toTitleCase: Converts a given title to title case for better readability.
extractUtmParams: Extracts UTM parameters (utm_source, utm_campaign) from a data object.
validateBaseUrl: Ensures that a base URL is provided before constructing a full URL.
constructUrlWithParams: Builds a full URL by appending a slug to a base URL and adding UTM parameters.
notifyAuthorByEmail: Simulates sending an email notification to an author by logging the notification to the console.
logChanges: Records changes made to a document by storing logs in the system, including the action taken and the user who performed it.
Note: The logChanges
and notifyAuthorByEmail
functions can be improved by converting them into Strapi services.
Creating a custom Strapi service for logging and notifications would allow us to reuse these functions across multiple content types and collections, making the system more modular, scalable, and maintainable.
This approach ensures that logging and notifications remain consistent throughout the application while simplifying middleware logic.
We just took a look at how to use the document service middleware in our project with few simple examples. Would love to see what you are going to build based on what we learned.
Conclusion
The shift from lifecycle hooks to document service middleware in Strapi 5 might have been a bit of a pain, but ultimately it will save you time and reduce headaches in the future. Middleware offers a more flexible and powerful solution, especially for keeping track of complex features like draft-and-publish and localized content.
And remember, lifecycle hooks still exist; they aren't deprecated, we're not removing them, they're just for purposes you probably don't need anymore. They're now exclusively intended for hooking into database activity.
Github Project Repo
You can find the complete code for this project in the following Github repo.
Strapi Open Office Hours
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours Monday through Friday at 12:30 pm - 1:30 pm CST: Strapi Discord Open Office Hours
Useful Links
Here are some helpful links to guide your journey:
- Database lifecycle Hooks vs Document Service Middlewares
- Strapi Migration Guide
- Strapi Document Service Middleware GPT assistant is a little AI-powered assistant I threw together to support this article to help with migrating to or writing document service middleware. Just promise to read the code it gives you before putting it into production.
Ben is an American living in Italy who has been developing web apps since long before it was cool. In his spare time, he likes cooking and eating, which go together perfectly.