In this post, we will take a look at how customize Strapi dashboard by building a widget plugin for Strapi.
Strapi Widgets are a way to add custom widgets to the Strapi admin panel. They are a great way to add customize Strapi dashboard for you clients.
Build your own dashboard The Strapi admin homepage is now fully customizable.
With the new Widget API, developers can create dashboard components that display:
- Project stats
- Content overviews
- Links to workflows
- Custom metrics or visualizations
- And more
It’s a new way to surface what matters most for each team.
Let's first take a look at what we will be building, then I will walk you through the steps on how to build it.
What We Will Be Building
We will be building a widget that displays the number of content types in the Strapi application.
Here is what the widget will look like in the admin panel:
This guide is based on Strapi v5 docs. You can find the original docs here.
If you prefer to watch a video, you can check out the following video:
I wanted to make a guide that is more hands on and practical. So I will walk you through the steps of building the widget.
Action Plan
- Create a new Strapi application with sample data
- Create a new Strapi plugin
- Create Frontend Component and register it as a widget
- Create Backend Controller and routes ( Admin and Content API )
- Update Frontend Component to fetch our test data
- Add custom logic to the controller
- Update the component to fetch data from the controller
Step 1: Create a new Strapi application with sample data
This step is very simple. We will use the Strapi CLI to create a new Strapi application with sample data.
npx create-strapi-app@latest my-strapi-app
You will be guided through the process of creating the application.
Need to install the following packages:
create-strapi-app@5.14.0
Ok to proceed? (y) y
You will be asked if you want to log in to Strapi Cloud. BTW, we now offer a free Strapi Cloud account for development purposes. Learn more here.
But I will skip this step for now.
Create your free Strapi Cloud project now!
? Please log in or sign up.
Login/Sign up
❯ Skip
I will answer Y
for all the following questions.
? Please log in or sign up. Skip
? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? Yes
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? Yes
This will create a new Strapi application with sample data. Now, let's start the application by running the following command.
cd my-strapi-app
npm run dev
This will start the Strapi application. You can access the admin panel at http://localhost:1337/admin
.
Go ahead and create a new Admin User.
Once logged in, you will be greeted with the following screen.
Nice, now we have a Strapi application with sample data. We can start building our widget.
Step 2: Create a new Strapi plugin
To simplify the process of setting up a Strapi plugin, we will use the Strapi Plugin CLI tool to help us accomplish this.
You can learn more about the Strapi Plugin CLI tool here.
We will start with the following command.
npx @strapi/sdk-plugin@latest init my-first-widget
Make sure to run this command in the root of your Strapi application.
This will create a new plugin in the src/plugins
directory.
You will be asked the following questions:
[INFO] Creating a new package at: src/plugins/my-first-widget
✔ plugin name … my-first-widget
✔ plugin display name … My First Widget
✔ plugin description … Basic Strapi widget example.
✔ plugin author name … paul brats
✔ plugin author email … paul.bratslavsky@strapi.io
✔ git url … ( you can leave this blank for now)
✔ plugin license … MIT
✔ register with the admin panel? … yes
✔ register with the server? … yes
✔ use editorconfig? … yes
✔ use eslint? … yes
✔ use prettier? … yes
✔ use typescript? … yes
note: Make sure to answer yes
to register the plugin with both the admin panel
and the server
.
This will create a new plugin in the src/plugins
directory.
Finally, we need to register the plugin in the config/plugins.ts
file found in the root Strapi directory.
You can enable your plugin by adding the following:
1// config/plugins.ts
2────────────────────────────────────────────
3export default {
4 // ...
5 'my-first-widget': {
6 enabled: true,
7 resolve: './src/plugins/my-first-widget'
8 },
9 // ...
10}
This will enable the plugin and point to the plugin's entry point.
Now to test that everything is working, first in your terminal navigate to the src/plugins/my-first-widget
directory and run the following command:
npm run build
npm run watch
This will build the plugin and start the plugin in watch mode with hot reloading.
Now in another terminal navigate to the root of your Strapi application and run the following command:
npm run dev
This will start the Strapi application. You should be able to find your plugin in the admin panel in the left sidebar.
Nice, now we have a plugin that is working. Let's create a new widget.
- Create Frontend Component and register it as a widget
First, let's quickly take a look at the plugin structure. We will just focus on the most important items where we will be working in.
src/plugins/my-first-widget
├── admin
├── server
admin - This is the responsible for all of our frontend code. server - This is the responsible for all of our backend code.
Remember earlier I asked you to answer yes
to register with the admin panel and the server this will hook everything together.
Taking a look at the admin
folder more closely we will see the following:
src/plugins/my-first-widget/admin
├── src |
│ ├── components
│ ├── pages
│ └── index.ts
index.ts - This is the entry point for our plugin. It is responsible for registering and configuring the plugin with the admin panel. components - This is the responsible for all of our frontend components. pages - This is the responsible for all frontend pages and routes.
We will take a look at the server
folder more closely later. But for now, let's register a new widget.
We will start by creating a new component in the src/components
folder named MetricsWidget.tsx
.
src/plugins/my-first-widget/admin/src/components
├── MetricsWidget.tsx
And and the following code:
1const MetricsWidget = () => {
2 return (
3 <div>
4 <h1>Hello World from my first widget</h1>
5 </div>
6 );
7};
8
9export default MetricsWidget;
Now that we have a component, we need to register it as a widget.
To do this, let's navigate to the admin/src/index.ts
file and start by removing the following code:
1app.addMenuLink({
2 to: `plugins/${PLUGIN_ID}`,
3 icon: PluginIcon,
4 intlLabel: {
5 id: `${PLUGIN_ID}.plugin.name`,
6 defaultMessage: PLUGIN_ID,
7 },
8 Component: async () => {
9 const { App } = await import("./pages/App");
10 return App;
11 },
12});
The above code is responsible for registering our plugin in the admin panel menu, not something we need for this use case.
Next, let's register our widget component that we created earlier by adding the following code:
1app.widgets.register({
2 icon: Stethoscope,
3 title: {
4 id: `${PLUGIN_ID}.widget.metrics.title`,
5 defaultMessage: "Content Metrics",
6 },
7 component: async () => {
8 const component = await import("./components/MetricsWidget");
9 return component.default;
10 },
11 id: "content-metrics",
12 pluginId: PLUGIN_ID,
13});
Make sure to import the Stethoscope
icon from @strapi/icons-react
.
1import { Stethoscope } from "@strapi/icons";
The completed file should look like this:
1import { PLUGIN_ID } from "./pluginId";
2import { Initializer } from "./components/Initializer";
3import { Stethoscope } from "@strapi/icons";
4export default {
5 register(app: any) {
6 app.widgets.register({
7 icon: Stethoscope,
8 title: {
9 id: `${PLUGIN_ID}.widget.metrics.title`,
10 defaultMessage: "Content Metrics",
11 },
12 component: async () => {
13 const component = await import("./components/MetricsWidget");
14 return component.default;
15 },
16 id: "content-metrics",
17 pluginId: PLUGIN_ID,
18 });
19
20 app.registerPlugin({
21 id: PLUGIN_ID,
22 initializer: Initializer,
23 isReady: false,
24 name: PLUGIN_ID,
25 });
26 },
27
28 async registerTrads({ locales }: { locales: string[] }) {
29 return Promise.all(
30 locales.map(async (locale) => {
31 try {
32 const { default: data } = await import(
33 `./translations/${locale}.json`
34 );
35
36 return { data, locale };
37 } catch {
38 return { data: {}, locale };
39 }
40 })
41 );
42 },
43};
If your Strapi application is running, you should be able to see the widget in the admin panel.
note: If your application is not running, you can start it by running the following commands:
Navigate to the src/plugins/my-first-widget
directory and run the following commands:
npm run build
npm run watch
And in another terminal navigate to the root of your Strapi application and run the following command:
// in the root of your Strapi application
npm run dev
Nice, now we have a widget that is working. Le't take a look how we can crete a custom controller and routes to fetch data for our widget.
Step 4: Create a custom controller and routes
Let's revisit the plugin structure.
src/plugins/my-first-widget
├── admin
├── server
We will be working in the server
folder. We should see the following structure:
src/plugins/my-first-widget/server
├── src
│ ├── config
│ ├── content-types
│ ├── controllers
│ ├── middlewares
│ ├── policies
│ ├── routes
│ ├── services
│ ├── bootstrap.ts
│ ├── destroy.ts
│ ├── index.js
│ └── register.ts
For this tutorial, we will be working in the src/controllers
and src/routes
folders.
You can learn more about Strapi backend customizations here.
Let's start by creating a new controller. You can learn more about Strapi controllers here.
Let's navigate to the src/controllers
and make the following changes in the controller.ts
file.
1import type { Core } from "@strapi/strapi";
2
3const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
4 async getContentCounts(ctx) {
5 try {
6 // TODO: Add custom logic here
7 ctx.body = { message: "Hello from the server" };
8 } catch (err) {
9 ctx.throw(500, err);
10 }
11 },
12});
13
14export default controller;
Now that we have our basic controller, let's update the routes to be able to fetch data from our controller in the "Content API" and "Admin API".
Content API - This is the API that is used to fetch data from the public website.
Admin API - This is the API that is used to fetch data from the admin panel. This is the API that is used to fetch data from the admin panel.
Let's navigate to the src/routes
folder and make the following changes:
In the content-api.ts
file, let's make the following changes:
1const routes = [
2 {
3 method: "GET",
4 path: "/count",
5 handler: "controller.getContentCounts",
6 config: {
7 policies: [],
8 },
9 },
10];
11
12export default routes;
This will create a new route that will be able to fetch data from our controller that we can use to fetch data from an external frontend application.
Let's try it out.
First, in the Admin Panel navigate to the Settings -> Users & Permissions plugin -> Roles -> Public
role. You should now see our newly created custom route ( getCustomCounts ) from our plugin that powers our widget.
We can test it out in Postman by making a GET
request to the following URL:
1http://localhost:1337/api/my-first-widget/count
We should see the following response:
1{
2 "message": "Hello from the server"
3}
Now that we have a working Content API route, let's see how we can do the same by creating a Admin API route that will be internal route used by our Strapi Admin Panel.
Let's navigate to the src/routes
folder and make the following changes:
First let's create a new file named admin-api.ts
and add the following code:
1const routes = [
2 {
3 method: "GET",
4 path: "/count",
5 handler: "controller.getContentCounts",
6 config: {
7 policies: [],
8 },
9 },
10];
11
12export default routes;
Now, let's update the index.js
file to include our new route.
1import adminAPIRoutes from "./admin-api";
2import contentAPIRoutes from "./content-api";
3
4const routes = {
5 admin: {
6 type: "admin",
7 routes: adminAPIRoutes,
8 },
9 "content-api": {
10 type: "content-api",
11 routes: contentAPIRoutes,
12 },
13};
14
15export default routes;
Step 5: Update Frontend Component to fetch our test data
And finally, let's update our component in the src/components/MetricsWidget.tsx
file to fetch data from our new route.
To accomplish this, we will use the useFetchClient
provided by Strapi.
Let's update the MetricsWidget.tsx
file with the following code:
1import { useState, useEffect } from "react";
2import { useFetchClient } from "@strapi/strapi/admin";
3
4import { Widget } from "@strapi/admin/strapi-admin";
5
6import { PLUGIN_ID } from "../pluginId";
7const PATH = "/count";
8
9const MetricsWidget = () => {
10 const { get } = useFetchClient();
11
12 const [loading, setLoading] = useState(true);
13 const [metrics, setMetrics] = useState<Record<
14 string,
15 string | number
16 > | null>(null);
17 const [error, setError] = useState<string | null>(null);
18
19 useEffect(() => {
20 const fetchMetrics = async () => {
21 try {
22 const { data } = await get(PLUGIN_ID + PATH);
23 console.log("data:", data);
24
25 const formattedData = data.message;
26 setMetrics(formattedData);
27 setLoading(false);
28 } catch (err) {
29 console.error(err);
30 setError(err instanceof Error ? err.message : "An error occurred");
31 setLoading(false);
32 }
33 };
34
35 fetchMetrics();
36 }, []);
37
38 if (loading) {
39 return <Widget.Loading />;
40 }
41
42 if (error) {
43 return <Widget.Error />;
44 }
45
46 if (!metrics || Object.keys(metrics).length === 0) {
47 return <Widget.NoData>No content types found</Widget.NoData>;
48 }
49
50 return (
51 <div>
52 <h1>Hello World from my first widget</h1>
53 <p>{JSON.stringify(metrics)}</p>
54 </div>
55 );
56};
57
58export default MetricsWidget;
Now, if you navigate to the Admin Panel you should see the following:
Nice, now we have a widget that is working. Let's add some custom logic to the controller to fetch data from our database.
Step 6: Add custom logic to the controller
Let's navigate to the server/src/controllers
folder and make the following changes:
1import type { Core } from "@strapi/strapi";
2
3const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
4 async getContentCounts(ctx) {
5 try {
6 //Get all content types
7 const contentTypes = await Object.keys(strapi.contentTypes)
8 .filter((uid) => uid.startsWith("api::"))
9 .reduce((acc, uid) => {
10 const contentType = strapi.contentTypes[uid];
11 acc[contentType.info.displayName || uid] = 0;
12 return acc;
13 }, {});
14
15 // Count entities for each content type using Document Service
16 for (const [name, _] of Object.entries(contentTypes)) {
17 const uid = Object.keys(strapi.contentTypes).find(
18 (key) =>
19 strapi.contentTypes[key].info.displayName === name || key === name
20 );
21
22 if (uid) {
23 // Using the count() method from Document Service instead of strapi.db.query
24 const count = await strapi.documents(uid as any).count({});
25 contentTypes[name] = count;
26 }
27 }
28 ctx.body = contentTypes;
29 } catch (err) {
30 ctx.throw(500, err);
31 }
32 },
33});
34
35export default controller;
In the code above we define a custom Strapi controller that returns a count of entries for each API-defined content type in your project.
Filters content types: It filters all available content types to only include the ones defined under the api:: namespace — that means custom content types you've created in your project (not admin, plugin, or built-in types).
Initializes a contentTypes object: For each content type, it adds an entry with a display name and initializes the count to 0.
Counts entries using the new Document Service: It loops over each content type and uses strapi.documents(uid).count({}) to get the total number of entries in the collection.
💡 This uses the new Document Service introduced in Strapi v5, which is a higher-level abstraction compared to strapi.db.query.
Sets the count for each content type in the final response object.
Step 7: Update the Frontend Component to fetch data from the controller
Let's navigate to the src/components/MetricsWidget.tsx
file and make the following changes:
We will update the useEffect
hook with the following code:
1const formattedData: Record<string, string | number> = {};
2
3if (data && typeof data === "object") {
4 await Promise.all(
5 Object.entries(data).map(async ([key, value]) => {
6 if (typeof value === "function") {
7 const result = await value();
8 formattedData[key] =
9 typeof result === "number" ? result : String(result);
10 } else {
11 formattedData[key] = typeof value === "number" ? value : String(value);
12 }
13 })
14 );
15}
This will fetch the data from the controller and format it to be used in the frontend component.
The updated MetricsWidget.tsx
file should look like this:
1import { useState, useEffect } from "react";
2import { useFetchClient } from "@strapi/strapi/admin";
3
4import { Widget } from "@strapi/admin/strapi-admin";
5
6import { PLUGIN_ID } from "../pluginId";
7const PATH = "/count";
8
9const MetricsWidget = () => {
10 const { get } = useFetchClient();
11
12 const [loading, setLoading] = useState(true);
13 const [metrics, setMetrics] = useState<Record<
14 string,
15 string | number
16 > | null>(null);
17 const [error, setError] = useState<string | null>(null);
18
19 const formattedData: Record<string, string | number> = {};
20
21 if (data && typeof data === "object") {
22 await Promise.all(
23 Object.entries(data).map(async ([key, value]) => {
24 if (typeof value === "function") {
25 const result = await value();
26 formattedData[key] =
27 typeof result === "number" ? result : String(result);
28 } else {
29 formattedData[key] =
30 typeof value === "number" ? value : String(value);
31 }
32 })
33 );
34 }
35
36 setMetrics(formattedData);
37 setLoading(false);
38 } catch (err) {
39 console.error(err);
40 setError(err instanceof Error ? err.message : "An error occurred");
41 setLoading(false);
42 }
43 };
44
45 fetchMetrics();
46 }, []);
47
48 if (loading) {
49 return <Widget.Loading />;
50 }
51
52 if (error) {
53 return <Widget.Error />;
54 }
55
56 if (!metrics || Object.keys(metrics).length === 0) {
57 return <Widget.NoData>No content types found</Widget.NoData>;
58 }
59
60 return (
61 useEffect(() => {
62 const fetchMetrics = async () => {
63 try {
64 const { data } = await get(PLUGIN_ID + PATH);
65 console.log("data:", data);
66
67 <div>
68 <h1>Hello World from my first widget</h1>
69 <p>{JSON.stringify(metrics)}</p>
70 </div>
71 );
72};
73
74export default MetricsWidget;
Now, if you navigate to the Admin Panel you should see the following:
Finally, let's make it prettier by adding a Table component from Strapi Design System.
Let's start by importing the components from Strapi Design System.
1import { Table, Tbody, Tr, Td, Typography } from "@strapi/design-system";
Now, let's update the MetricsWidget.tsx
file to use the Table component. Make the following changes in the return
statement:
1return (
2 <Table>
3 <Tbody>
4 {Object.entries(metrics).map(([contentType, count], index) => (
5 <Tr key={index}>
6 <Td>
7 <Typography variant="omega">{String(contentType)}</Typography>
8 </Td>
9 <Td>
10 <Typography variant="omega" fontWeight="bold">
11 {String(count)}
12 </Typography>
13 </Td>
14 </Tr>
15 ))}
16 </Tbody>
17 </Table>
18);
The updated MetricsWidget.tsx
file should look like this:
1import { useState, useEffect } from 'react';
2import { useFetchClient } from '@strapi/strapi/admin';
3
4import { Widget } from '@strapi/admin/strapi-admin';
5import { Table, Tbody, Tr, Td, Typography } from "@strapi/design-system";
6
7import { PLUGIN_ID } from '../pluginId';
8const PATH = '/count';
9
10const MetricsWidget = () => {
11 const { get } = useFetchClient();
12
13 const [loading, setLoading] = useState(true);
14 const [metrics, setMetrics] = useState<Record<string, string | number> | null>(null);
15 const [error, setError] = useState<string | null>(null);
16
17 useEffect(() => {
18 const fetchMetrics = async () => {
19 try {
20 const { data } = await get(PLUGIN_ID + PATH);
21 console.log('data:', data);
22
23 const formattedData: Record<string, string | number> = {};
24
25 if (data && typeof data === 'object') {
26 await Promise.all(
27 Object.entries(data).map(async ([key, value]) => {
28 if (typeof value === 'function') {
29 const result = await value();
30 formattedData[key] = typeof result === 'number' ? result : String(result);
31 } else {
32 formattedData[key] = typeof value === 'number' ? value : String(value);
33 }
34 })
35 );
36 }
37
38 setMetrics(formattedData);
39 setLoading(false);
40 } catch (err) {
41 console.error(err);
42 setError(err instanceof Error ? err.message : 'An error occurred');
43 setLoading(false);
44 }
45 };
46
47 fetchMetrics();
48 }, []);
49
50 if (loading) {
51 return <Widget.Loading />;
52 }
53
54 if (error) {
55 return <Widget.Error />;
56 }
57
58 if (!metrics || Object.keys(metrics).length === 0) {
59 return <Widget.NoData>No content types found</Widget.NoData>;
60 }
61
62 return (
63 <Table>
64 <Tbody>
65 {Object.entries(metrics).map(([contentType, count], index) => (
66 <Tr key={index}>
67 <Td>
68 <Typography variant="omega">{String(contentType)}</Typography>
69 </Td>
70 <Td>
71 <Typography variant="omega" fontWeight="bold">
72 {String(count)}
73 </Typography>
74 </Td>
75 </Tr>
76 ))}
77 </Tbody>
78 </Table>
79 );
80};
81
82export default MetricsWidget;
Now, if you navigate to the Admin Panel you should see the final result:
Yay, we have a cool new widget that displays the number of content types in our project.
Now, you can start building your own widgets and share them with the community.
Conclusion
Building a custom widget for Strapi may seem complex at first, but once you go through it step by step, it’s actually pretty straightforward.
You now have a working widget that shows content counts right inside the Strapi admin panel—and it looks great thanks to the built-in Design System.
Widgets like this can be a powerful way to add helpful tools for your team or clients.
🔑 What You Learned
- How to create a plugin using the Strapi Plugin CLI.
- How to build a custom widget and show it on the admin dashboard.
- Where frontend and backend code lives in a plugin.
- How to fetch content data using the new Document Service in Strapi v5.
- How to add custom API routes for both admin and public use.
- How to use Strapi’s Design System to keep your UI clean and consistent.
You can now build your own widgets, pull real data from your backend, and customize the admin panel to better fit your needs.
Now that you know how to customize Strapi dashboard via widget. We would love to see what you will build.
👉 Get the full code here: strapi-widget-example on GitHub.
Join the Strapi community: Come hang out with us during our "Open Office" hours on Discord.
We are there Monday through Friday from 12:30pm CST time.
Stop on by to chat, ask questions, or just say hi!