Skip to main content

Lifecycle Hooks

Lifecycle hooks run automatically before or after database operations. They are the right place for auto-computed fields, audit logging, cache invalidation, and side effects that should always happen regardless of which controller triggers the operation.

Document Service middleware as lifecycle hooks

In Strapi 5, lifecycle hooks are implemented as Document Service middleware. They intercept calls on the Document Service API.

Registration

Register all document service middleware in src/index.js (or src/index.ts):

// src/index.js
module.exports = {
register({ strapi }) {
// Register middleware here
strapi.documents.use(async (context, next) => {
// runs for ALL content types and ALL actions
const result = await next();
return result;
});
},
};

The context object

Every middleware receives a context with:

PropertyTypeDescription
uidstringContent type UID (e.g., api::article.article)
actionstringMethod name: findOne, findMany, create, update, delete, publish, unpublish
paramsobjectThe parameters passed to the method (data, filters, populate, etc.)
contentTypeobjectFull content type schema

Practical examples

Auto-generate slugs before create/update

// src/index.js
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== 'api::article.article') {
return next();
}

if (['create', 'update'].includes(context.action)) {
const title = context.params.data?.title;

if (title) {
context.params.data.slug = title
.toLowerCase()
.replace(/[äöü]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue' }[c]))
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}

return next();
});
},
};

Audit log: track who changed what

// src/index.js
const auditableTypes = [
'api::article.article',
'api::page.page',
'api::product.product',
];

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (!auditableTypes.includes(context.uid)) {
return next();
}

if (!['create', 'update', 'delete'].includes(context.action)) {
return next();
}

const result = await next();

// Log the change asynchronously (don't block the response)
setImmediate(async () => {
try {
await strapi.documents('api::audit-log.audit-log').create({
data: {
contentType: context.uid,
action: context.action,
documentId: context.params.documentId || result?.documentId,
data: JSON.stringify(context.params.data || {}),
timestamp: new Date().toISOString(),
},
});
} catch (error) {
strapi.log.error('Audit log failed:', error);
}
});

return result;
});
},
};

Validate data before create

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid === 'api::product.product' && context.action === 'create') {
const { price, name } = context.params.data || {};

if (!name || name.trim().length < 2) {
throw new Error('Product name must be at least 2 characters');
}

if (price !== undefined && price < 0) {
throw new Error('Price cannot be negative');
}
}

return next();
});
},
};

Cache invalidation after mutations

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

// Invalidate cache after any write operation
if (['create', 'update', 'delete', 'publish', 'unpublish'].includes(context.action)) {
const pattern = `strapi:${context.uid}:*`;

const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
strapi.log.debug(`Invalidated ${keys.length} cache entries for ${context.uid}`);
}
}

return result;
});
},
};

Send email on publish

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (
context.uid === 'api::article.article' &&
context.action === 'publish'
) {
setImmediate(async () => {
try {
const article = await strapi.documents('api::article.article').findOne(
result.documentId,
{ populate: ['author'] }
);

if (article?.author?.email) {
await strapi.plugins['email'].services.email.send({
to: article.author.email,
subject: `Your article "${article.title}" has been published!`,
html: `<p>Your article is now live. <a href="https://example.com/articles/${article.slug}">View it here</a>.</p>`,
});
}
} catch (err) {
strapi.log.error('Publish notification failed:', err);
}
});
}

return result;
});
},
};

Compute fields after create/update

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (
context.uid === 'api::article.article' &&
['create', 'update'].includes(context.action)
) {
const content = result?.content || '';
const wordCount = content.split(/\s+/).filter(Boolean).length;
const readingTime = Math.ceil(wordCount / 200);

// Update silently without re-triggering middleware
await strapi.db.query('api::article.article').update({
where: { documentId: result.documentId },
data: { readingTime, wordCount },
});
}

return result;
});
},
};

Multiple middleware: execution order

Middleware runs in the order it is registered. Each one wraps the next:

module.exports = {
register({ strapi }) {
// Middleware 1 -- runs first (before) and last (after)
strapi.documents.use(async (context, next) => {
strapi.log.debug('Middleware 1: before');
const result = await next();
strapi.log.debug('Middleware 1: after');
return result;
});

// Middleware 2 -- runs second (before) and second-to-last (after)
strapi.documents.use(async (context, next) => {
strapi.log.debug('Middleware 2: before');
const result = await next();
strapi.log.debug('Middleware 2: after');
return result;
});
},
};

// Output order:
// Middleware 1: before
// Middleware 2: before
// (database operation)
// Middleware 2: after
// Middleware 1: after

Common pitfalls

PitfallProblemFix
Not returning next()Breaks the entire middleware chainAlways return next() or return await next()
Blocking side effectsSlow responses if email/webhook is synchronousUse setImmediate() for fire-and-forget tasks
Infinite loopsMiddleware triggers another update which triggers itselfUse strapi.db.query() for silent updates
No UID guardMiddleware runs for all content typesCheck context.uid early and return next()
Heavy computationsSlows down every matching operationMove to a queue or async job

See also