Strapi CMS Admin Panel Customization Post-Version 4.15.0

Strapi is a great content management system, and my go-to these days. Often, the underdog Scrappy-Doo comes to mind. Like a show with failing ratings, I've grown the need add some character to Strapi. Custom configuration and utilities have accumulated over time, and patch-package has served me well.

# Problem

Recently, with the release of v4.15.0, the customization I once injected via patch-package no longer has a path into the build. As a Strapi power-user, I'd like to redirect to a custom dashboard after logging in to the Strapi dashboard. Let's explore a solution I found on the Strapi Forums!

tl;dr? Have a peek at the GitHub repo, or, even faster, the Gist.

# Demo

Still frame: A still from of the animated demo. Strapi logs in and redirects to the "Dashbored" plugin.
Animated

Strapi redirecting to our custom plugin, "Dashbored". Add anything you like, perhaps an analytics dashboard? It's your dog and pony show.

# Details

We're going to save what we can from ./patches directly into a Strapi plugin, and try to handle redirection from the ho-hum /admin route to our handy custom dashboard.

# Minor Change?

Recently the Strapi team started to refactor the entire codebase with TypeScript, and are doing so with standard releases.

Why this was done is another conversation, but needless to say, the plugin and developer community were blindsided to find components and logic that were once customizable at build time — GASP — have been hidden away in obfuscation.

Some time around Strapi v4.15.0, most of the application started shipping without its source. The project now includes only compiled code, leaving no way for a downstream developer to customize Strapi in a way previously possible.

Oddly, Strapi's own documentation remains unchanged in this regard, indicating patch-package as a means of applying advanced customization. But I digress, let's take a look at what can be done!

# Solution, Part 1

I took some time to throw away the patch-package patches I'd put together, and recreate them with a Strapi plugin. This was easy, and gets us 95% of the way to a fully customized landing page after sign in.

We're going to:

  1. Install Strapi
  2. Create a plugin
  3. Add custom middleware
  4. Use Strapi's design system
  5. Install, configure, and use the plugin

Let's get started!

# Install Strapi

We could use an existing installation, but starting fresh might be a good idea if poking a production application makes you queazy.

create-strapi-app
npx create-strapi-app@latest --quickstart strapi-dashbored

That's not a typo. Our plugin shall be called "strapi-dashbored". The above command installs Strapi with a basic sqlite database, so we don't need to do any additional configuration.

If creating a new installation, set up a super admin account now.

The Strapi welcome page, where one creates the super-admin user.

We will be brought to the Strapi Dashboard, which in all its exciting and mystical glory, looks like this:

The default Strapi admin dashboard page, which reads "Welcome on board", and provides links to Strapi marketing content.

Let's dig in.

# Add the "Dashbored" Plugin

Strapi includes a bunch of useful command line utilities. A list is available via npm run strapi -- --help. Let's run the strapi generate command. Remember to cd path/to/install before running the below!

❯ npm run strapi generate
> strapi-dashbored@0.1.0 strapi
> strapi generate

  Strapi Generators
  api - Generate a basic API
  controller - Generate a controller for an API
  content-type - Generate a content type for an API
❯ plugin - Generate a basic plugin
  policy - Generate a policy for an API
  middleware - Generate a middleware for an API
  migration - Generate a migration
(Move up and down to reveal more choices)

The command line dialog will ask what we're trying to generate. Arrow-key down to plugin and press enter. Then, pick a name for the plugin:

? Plugin name dashbored

Select the flavor of JavaScript:

? Choose your preferred language (Use arrow keys)
❯ JavaScript
  TypeScript

The plugin will be generated, and Strapi provides us with the next step. If following along with a new installation, or plugins are uncharted territory, the plugins.js file may need to be created.

You can now enable your plugin by adding the following in ./config/plugins.js
──────────────────────────────────────
module.exports = {
  // ...
  'dashbored': {
    enabled: true,
    resolve: './src/plugins/dashbored'
  },
  // ...
}
──────────────────────────────────────

My applied changes reflecting this addition (File: config/plugins.js):

plugins.js
module.exports = {
  dashbored: {
    enabled: true,
    resolve: "./src/plugins/dashbored",
  },
};

After adding the above, we should rebuild the project for good measure. This isn't always needed, but is good muscle-memory to build.

❯ npm run build
> strapi-dashbored@0.1.0 build
> strapi build

⠋ Building build context
[INFO] Including the following ENV variables as part of the JS bundle:
    - ADMIN_PATH
    - STRAPI_ADMIN_BACKEND_URL
    - STRAPI_TELEMETRY_DISABLED
✔ Building build context (48ms)
⠋ Building admin panel
✔ Building admin panel (4853ms)

Great! Let's take a peek at the admin panel now. Start Strapi again via npm run develop.

Strapi default admin dashboard with our "Dashbored" plugin installed and displaying in the sidebar navigation.

Thar she blows! Our plugin is now installed, showing in the side nav, and ready to for a hot branding.

# Customize the Plugin

The only function our plugin has at the moment is looking bad in dark mode. It seems someone forgot to use the Strapi Design System while creating the plugin generator scripts. Hmmm?

¯\_(ツ)_/¯

The default plugin home page after installation and enabling.

# Name & Description

The main display of a plugin's name and description in the admin panel is controlled by a strapi property in the package.json file in its directory. Update the name, description, and displayName fields:

File: src/plugins/dashbored/package.json

package.json
5
"strapi": {
6
  "name": "Dashbored",
7
  "description": "Now you're playing with power!",
8
  "kind": "plugin",
9
  "displayName": "Dashbored"
10
},

The default plugin icon feels off. Do we want a puzzle piece puzzling our users? No! Onward.

# Plugin Icon

The plugin's Side Panel icon can be customized. We'll change the icon using built-in Strapi Design System icons.

Options (some of which are no longer work!), can be found here thanks to GitHub genius @andresmv94.

As this is a dashboard plugin, let's use the <Dashboard /> icon. Pop open src/plugins/dashbored/PluginIcon/index.js and adjust like so:

PluginIcon/index.js
/**
 *
 * PluginIcon
 *
 */

import React from 'react';
import { Dashboard } from '@strapi/icons';

const PluginIcon = () => <Dashboard />;

export default PluginIcon;

The Dashbored plugin side navigation label now with a capital D. Remember to npm run build , npm run develop, and hard-refresh your browser to see the icon and capitalization change.!

# Plugin Sanity-Check

Where one was once able to peruse layouts in node_modules/@strapi/admin/admin/src locally, you will find nothing but chunked exports and imports. Take a peek at the GitHub repo and Strapi Design System for layout and content ideas.

Let's add some custom UI to the plugin Home Page by editing — you guessed it — the HomePage component:

HomePage/index.js
/*
 *
 * HomePage
 *
 */

import React from "react";
// import PropTypes from 'prop-types';
import pluginId from "../../pluginId";
const HomePage = () => {
  return (
    <div>
      <h1>{pluginId}'s Bacon Bed, Eggs, and Breakfast</h1>
      <p>Happy coding</p>
    </div>
  );
};

export default HomePage;

Remember to npm run build again before banging your head against any walls. After a build and npm run develop, we can see the change:

The plugin home page with updated text.

# Full Plugin View

Let's skip the Strapi Design system trudgery and take a peek at the final code and view. I stole most of this directly from the Strapi dashboard code.

Note

We may be attacked by the IDE's Red Squiggle Monster, TypeScript is likely angry about JSX flags. Pop open jsconfig.json in the project's base directory, and add "jsx": "react", to compilerOptions as such:

jsconfig.json
1
{
2
  "compilerOptions": {
3
    "jsx": "react",
4
    "moduleResolution": "nodenext",
5
    "target": "ES2021",
6
    "checkJs": true,
7
    "allowJs": true
8
  }
9
}

Open up the HomePage component and add the below (File: src/plugins/dashboard/admin/src/pages/HomePage/index.js).

Important

You'll also need the assets directory (on line 11). Grab the files here.

HomePage/index.js
1
/*
2
 * HomePage
3
 *
4
 */
5
import React from "react";
6
import { Box, Grid, GridItem, Layout, Main } from "@strapi/design-system";
7
import { Helmet } from "react-helmet";
8
import { FormattedMessage } from "react-intl";
9
import styled from "styled-components";
10

11
import cornerOrnamentPath from "./assets/corner-ornament.svg";
12
import ContentBlocks from "../../components/ContentBlocks";
13

14
const LogoContainer = styled(Box)`
15
  position: absolute;
16
  top: 0;
17
  right: 0;
18

19
  img {
20
    width: ${150 / 16}rem;
21
  }
22
`;
23

24
export const HomePageCE = () => {
25
  return (
26
    <Layout>
27
      <FormattedMessage id="HomePage.helmet.title" defaultMessage="Homepage">
28
        {(title) => <Helmet title={title[0]} />}
29
      </FormattedMessage>
30
      <Main>
31
        <LogoContainer>
32
          <img alt="" aria-hidden src={cornerOrnamentPath} />
33
        </LogoContainer>
34
        <Box padding={10}>
35
          <Grid>
36
            <GridItem col={12}>
37
              <ContentBlocks />
38
            </GridItem>
39
          </Grid>
40
        </Box>
41
      </Main>
42
    </Layout>
43
  );
44
};
45

46
function HomePageSwitch() {
47
  return <HomePageCE />;
48
}
49

50
export default HomePageSwitch;
51

Next, we'll add some blocks of content. Create File: src/plugins/dashboard/admin/src/components/ContentBlocks.js:

ContentBlocks.js
import React from "react";
import { useHistory } from "react-router-dom";
import { Box, Flex, Grid, GridItem } from "@strapi/design-system";
import { ContentBox, useTracking } from "@strapi/helper-plugin";
import {
  FeatherSquare,
  InformationSquare,
  ChartBubble,
  Crown,
} from "@strapi/icons";
import { useIntl } from "react-intl";
import styled from "styled-components";

const BlockLink = styled.a`
  text-decoration: none;
`;

const StyledChartBubble = styled(ChartBubble)`
  path {
    fill: #7289da !important;
  }
`;

const StyledInformationSquare = styled(InformationSquare)`
  path {
    stroke: #7289da !important;
  }
`;

const StyledCrown = styled(Crown)`
  path {
    fill: #7289da !important;
    stroke: #7289da !important;
  }
`;

const StyledFeatherSquare = styled(FeatherSquare)`
  path {
    stroke: #7289da !important;
  }
`;

const ContentBlocks = () => {
  const { formatMessage } = useIntl();
  const { trackUsage } = useTracking();

  const { push } = useHistory();
  const navigate = (e, url) => {
    e.preventDefault();
    push(url);
  };

  return (
    <Flex direction="column" alignItems="stretch" gap={5}>
      <Grid gap={5}>
        <GridItem col={3}>
          <BlockLink
            href="#"
            onClick={(e) => {
              navigate(
                e,
                "/content-manager/collectionType/api::post.post?page=1&pageSize=100"
              );
            }}
          >
            <ContentBox
              title={formatMessage({
                id: "dashbored.posts.title",
                defaultMessage: "Posts",
              })}
              subtitle={formatMessage({
                id: "dashbored.posts.label",
                defaultMessage: "Edit Posts",
              })}
              icon={<StyledFeatherSquare />}
              iconBackground="primary100"
            />
          </BlockLink>
        </GridItem>
        <GridItem col={3}>
          <BlockLink
            href="#"
            onClick={(e) => {
              navigate(
                e,
                "/content-manager/collectionType/api::project.project?page=1&pageSize=100"
              );
            }}
          >
            <ContentBox
              title={formatMessage({
                id: "dashbored.projects.title",
                defaultMessage: "Edit Projects",
              })}
              subtitle={formatMessage({
                id: "dashbored.projects.subtitle",
                defaultMessage: "Add or Manage Projects",
              })}
              icon={<StyledCrown />}
              iconBackground="primary100"
            />
          </BlockLink>
        </GridItem>
        <GridItem col={3}>
          <BlockLink
            href="#"
            onClick={(e) => {
              navigate(e, "/content-manager/singleType/api::home.home");
            }}
          >
            <ContentBox
              title={formatMessage({
                id: "app.components.HomePage.HomeContent.title",
                defaultMessage: "Edit Projects",
              })}
              subtitle={formatMessage({
                id: "app.components.HomePage.HomeContent.subtitle",
                defaultMessage: "Edit Project Content",
              })}
              icon={<StyledInformationSquare />}
              iconBackground="primary100"
            />
          </BlockLink>
        </GridItem>
        <GridItem col={3}>
          <BlockLink
            href="https://analytics.com"
            target="_blank"
            rel="noopener noreferrer nofollow"
          >
            <ContentBox
              title={formatMessage({
                id: "app.components.HomePage.analytics.title",
                defaultMessage: "Analytics",
              })}
              subtitle={formatMessage({
                id: "app.components.HomePage.analytics.subtitle",
                defaultMessage: "See the traffic",
              })}
              icon={<StyledChartBubble />}
              iconBackground="primary100"
            />
          </BlockLink>
        </GridItem>
      </Grid>
    </Flex>
  );
};

export default ContentBlocks;

A very important part of ContentBlocks.jsx is the import of { useHistory } from "react-router-dom"; (line 2 above). This must be used for internal Strapi admin links, otherwise you'll have full browser-reloads between pages.

There's a small navigate helper function to handle the links we've added on lines 48-51 below (File: src/plugins/dashbored/admin/src/components/ContentBlocks.js):

ContentBlocks.js
47
  const { push } = useHistory();
48
  const navigate = (e, url) => {
49
    e.preventDefault();
50
    push(url);
51
  };

It's used on the <BlockLink /> components (File: src/plugins/dashbored/admin/src/components/ContentBlocks.js):

ContentBlocks.js
1
<BlockLink
2
  href="#"
3
  onClick={(e) => {
4
    navigate(
5
      e,
6
      "/content-manager/collectionType/api::post.post?page=1&pageSize=100"
7
    );
8
  }}
9
>
10
  // ...
11
</BlockLink>

After the above changes have been applied, we may behold our custom dashboard, in all its glory:

The Dashbored plugin complete with custom link blocks.

Now, to semi-replace the admin dashboard with our own, let's look into applying redirects after authentication with Strapi.

# Solution, Part 2

After a user logs in to the CMS, they're redirected one of several places. For our usecase, this is the /admin route. This used to be as simple as patch-packageing the default HomePage component, but we can no longer have nice things.

Don't be fooled — this is great! Because we can learn about middlewares instead.

# Harry, You're a Middleware

As its name implies, middleware sits in the middle, between processes and actions in our Strapi application. Like chipping a dog with dromosagnosia, we can inject our own middleware into Strapi's configuration.

A great example is posted on StackOverflow, and we'll do just this, but have Strapi do some of the work. Let's use strapi generate again, but this time to conjure some middleware. It's as easy as

  1. npm run strapi generate
  2. arrow down to middleware
  3. name your middlware
  4. arrow down to Add middleware to root of project
  5. Abracadabra!
❯ npm run strapi generate
> strapi-dashbored@0.1.0 strapi
> strapi generate

? Strapi Generators middleware - Generate a middleware for an API
? Middleware name rebored
? Where do you want to add this middleware? Add middleware to root of project
✔  ++ /middlewares/rebored.js

This can just as easily be named "redirect", but I'm staying on brand. Because that's what marketing gurus do.

Let's add the redirects. Pop open src/middlewares/rebored.js, and scribe the below:

rebored.js
'use strict';

/**
* `rebored` middleware
*/

module.exports = (config, { strapi }) => {
  const redirects = ["/", "/index.html", "/admin", "/admin/"].map((path) => ({
    method: "GET",
    path,
    handler: (ctx) => ctx.redirect("/admin/plugins/dashbored"),
    config: { auth: false },
  }));
  
  strapi.server.routes(redirects);
};

Here, we add some redirection routes based on the paths logged-in users hit by default. The idea is to catch the dog before it runs too far.

Like our plugin, we must add this new middleware to Strapi's configuration. Unfurl config/middlewares.js . Let us wet our pens:

middlewares.js
1
module.exports = [
2
  'strapi::errors',
3
  'strapi::security',
4
  'strapi::cors',
5
  'strapi::poweredBy',
6
  'strapi::logger',
7
  'strapi::query',
8
  'strapi::body',
9
  'strapi::session',
10
  'strapi::favicon',
11
  'strapi::public',
12
  { resolve: "./src/middlewares/rebored" },
13
];

# Conclusion

That's it! After npm run building and restarting Strapi, we'll see the new behavior when signing in. Sometimes. It rarely works in develop mode, but has been working well for me in production and not-production environments.

If the old carnival-banner strewn "Welcome on board 👋" page strays from our new route, refresh the browser. Our now chipped wandering Strapi should find its way home to Dashbored land.

I hope this post brings together some ideas for the developer community. If anyone knows how to permanently override an internal Strapi route, to avoid race conditions between client and server, please share in the Gist!

# Update

I'm not sure why I hadn't thought to share a possible medium for your blank canvas until now. But it makes a great segue into another forthcoming post about Plausible Analytics, soon to come in 2024!

Here's the complete custom dashboard for dgrebb.com. Plausible's brand palette fits in quite nicely, don't you think?

The custom Strapi dashboard now complete with Plausible Analytics data.

One can do this by making a public link and shareable embed. Check out Plausible Analytics' guide.

Then, embed it into ContentBlocks.js, ~Line 146, like so:

ContentBlocks.js
143
      </BlockLink>
144
    </GridItem>
145
  </Grid>
146
  <Box padding={4} shadow="filterShadow">
147
    <iframe
148
      plausible-embed="true"
149
      id="plausible"
150
      src="https://plausible.io/share/dgrebb.com?auth=8sm72wfwKETpyquVl_Np_&embed=true&theme=dark"
151
      scrolling="no"
152
      frameBorder="0"
153
      loading="lazy"
154
      style={{ width: "1px", minWidth: "100%", height: "2800px" }}
155
    ></iframe>
156
    <script async src="https://plausible.io/js/embed.host.js"></script>
157
  </Box>
158
</Flex>

There's more Plausible Analytics next time, but for now feel free to read about the Privacy Policy, or check out analytics for dgrebb.com.

Back to Top