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:
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.
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.
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-appYou 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) yYou 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
❯ SkipI 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? YesThis 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 devThis 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.
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-widgetMake 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? … yesnote: 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 watchThis 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 devThis 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.
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
├── serveradmin - 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.tsindex.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.tsxAnd 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 watchAnd 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 devNice, 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.
Let's revisit the plugin structure.
src/plugins/my-first-widget
├── admin
├── serverWe 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.tsFor 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/countWe 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.
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.
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.
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
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!