Strapi plugins allow you to add extra functionalities and custom features to power up your Strapi application.
One of the features of the Strapi CMS is the ability it gives you to unlock the full potential of content management, thus allowing you to build custom features for yourself and the community. Victor Coisne, the VP of marketing at Strapi, explained this in his article "Building Communities That Drive Growth".
"Strapi builds trust and shows that member input matters. They also foster transparency through forums, AMAs, and regular updates that keep members informed and valued."
In this article, we will build a custom Strapi plugin that will allow us publish contents to Medium and Dev.to using their APIs from the Strapi admin panel.
This article is divided into two parts:
In this comprehensive tutorial, you will learn about the following:
To follow along with this tutorial, please consider the following:
One of the features of the Strapi CMS is the ability it gives you to unlock the full potential of content management. This means that contents like blog posts can also be published from Strapi to platforms like Medium and Dev.
In this article, we will make use of Dev API and Medium API, integrate them with Strapi headless CMS to allow us publish contents from the Strapi admin panel to these platforms.
Strapi plugins allow you to extend Strapi core features.
They can be in 4 forms.
To learn more about Strapi plugins, please visit its Strapi documentation page.
To develop a Strapi plugin, you will be making use of the Strapi design system. This is a collection of typographies, colors, components, icons, etc. that are in line with Strapi brand design.
The Strapi design system allows you to make Strapi's contributions more cohesive and to build plugins more efficiently.
This comes with a new Strapi app.
Install a new Strapi 5 application by running the command below. For this project, we will be using NPM; you can choose any package manager of your choice:
# npm
npx create-strapi-app@latest # npm
# yarn
yarn create strapi # yarn
# pnpm
pnpm create strapi # pnpm
The name of our project is plugin-project
as shown below. The terminal will ask you a few questions. Choose a Yes
or a No
depending on your setup options.
From the installation process, the name of our project is plugin-project
. Feel free to give it a name of your choice.
After the successful installation of your Strapi project, you should be able to start your Strapi application.
CD into your Strapi project:
cd plugin-project
Run the command below to start your Strapi development server:
npm run develop
The command above will redirect you to the Strapi admin registration page. Ensure you create a new admin user.
Strapi content types or models are data structures for the content we want to manage.
Create the Strapi collection types Blog and Tag. Later on, we will create a collection type called Post for our Strapi plugin.
Blog refers to entries we want to publish. Post on the other hand are entries containing details of a blog we want to publish such as the blog link, Medium link, etc. A Post entry will be created automatically using Strapi Life Cycle upon creating a Blog entry.
If you are new to Strapi, learn about content-types here.
To create a new Strapi collection type, navigate to the Content-type Builder and click on the "+ Create new collection type" as shown in the first image below. After that, proceed to enter the name of the Collection type and click "Continue" to create fields for each collection type as shown in the second image below.
Locate the Content-type Builder
Enter Collection Name and Click "Continue"
Now, create the following collection types.
Tag Collection Type This represents the category a blog post belongs to. It is required for both the Medium and Dev API. As we will see in the Blog collection type below, it has a "many to one" relation with the Blog collection, i.e., a blog entry can have more than one tag.
Field | Type | Description |
---|---|---|
blogTag | Text | Tag used for blog categorization |
Blog Collection Type
The Blog collection type represents the blog post. It has fields like canonicalUrl
and tags
, which are required parameters when making requests to the Medium API or the Dev API.
Field | Type | Description |
---|---|---|
title | Text | The title of the content |
content | Rich text (Markdown) | The main body content |
canonicalUrl | Text | The canonical URL for SEO. |
tags | Relation with Tag collection type. => "Blog has many Tags" | Associated tags for categorization. |
banner | Text | URL or path for the banner image |
🖐️ NOTE: Ensure you disable "Draft and publish" for the Blog collection type.
Make sure to disable the "Draft and Publish" feature for the Blog collection type by unchecking the option.
Disable Draft and Publish
At this point, your Strapi app is ready to be powered up with a plugin! Next, we will learn how to generate a Strapi plugin.
Because we want to be able to use the tags of blog entries when publishing to Medium and Dev, we want to make sure that the tags of a blog are returned when we make a request to get a blog entry.
Ensure API permission is enabled for the Tag collection type. Enable API Permission for Tag Collection
The Strapi plugin SDK is used to develop and publish Strapi plugins for NPM.
Initialize a new Strapi plugin using the command below. Ensure you are in your Strapi project directory content-publisher
. The name of our plugin is content-publisher
.
1npx @strapi/sdk-plugin init content-publisher
🖐️ NOTE: You can use the
--force
option afternpx
if you experience peer dependency error.
Make sure to answer the prompts that follow as shown below:
✔ plugin name … content-publisher
✔ plugin display name … content-publisher
✔ plugin description … This is a plugin that allows you to publish contents from Strapi to Dev.to, Medium.com and many more in the future
✔ plugin author name … Theodore Kelechukwu Onyejiaku
✔ plugin author email … theodoreonyejiaku@gmail.com
✔ git url …
✔ 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 with admin panel and server. This is because we want to have our plugin server and frontend.
If the plugin initialization is successful, you should see the success message "Plugin generated successfully" and instructions on how to enable the plugin, as shown below.
Enable Plugin
Head over to the plugin configuration file ./config/plugin.ts
and add the code below:
1// Path: ./config/plugin.ts
2
3export default {
4 // ...
5 'content-publisher': {
6 enabled: true,
7 resolve: './src/plugins/content-publisher'
8 },
9 // ...
10}
To start building our plugin, we need to make sure that any changes and rebuilds are updated by using the watch
command.
First, CD into the plugin directory of your Strapi project, ./src/plugins/content-publisher
. After that, run the command below:
npm run watch
You should now see your Strapi plugin in two ways
*Click on Settings > Plugins and find the content-publisher plugin*
Locate and click the new custom plugin icon.
The Strapi plugin structure is divided into 2 parts.
/admin
directory of the plugin./server
directory of the plugin.1src/
2┣ ...
3┣ plugins/
4┃ ┗ content-publisher/
5┃ ┣ admin/
6┃ ┃ ┣ src/
7┃ ┃ ┣ custom.d.ts
8┃ ┃ ┣ tsconfig.build.json
9┃ ┃ ┗ tsconfig.json
10┃ ┣ server/
11┃ ┃ ┣ src/
12┃ ┃ ┣ tsconfig.build.json
13┃ ┃ ┗ tsconfig.json
14┃ ┗ ...
15┗ index.ts
For a comprehensive understanding of the Strapi plugin structure, please visit this Strapi documentation page: Plugin structure.
Let's get busy!
We will modify the icon and page content of our plugin so that it can be seen live!
Locate the current plugin icon inside the components
folder. This is available Inside the ./src/plugins/content-publisher/admin/src/components/PluginIcon.tsx
file. Currently, it uses the puzzle-piece icon.
Update this file with the code below:
1// Path: src/plugins/content-publisher/admin/src/components/PluginIcon.tsx
2
3import { Sparkle } from '@strapi/icons';
4
5const PluginIcon = () => <Sparkle />;
6
7export { PluginIcon };
Now, the plugin icon has been updated to a sparkle icon, as shown in the image below. The current plugin icon can be updated to any icon you prefer. You could use an icon from the Strapi design system or an SVG of your choice that aligns with the Strapi design system.
At the moment, the content of the plugin displays "Welcome to content-publisher.plugin.name". Let's update it.
Update the code inside the ./src/plugins/content-publisher/admin/src/pages/HomePage.tsx
file with the following code:
1// Path: src/plugins/content-publisher/admin/src/pages/HomePage.tsx
2
3import { Main, Box, Typography } from '@strapi/design-system';
4
5const HomePage = () => {
6 return (
7 <Main padding={5}>
8 <Box paddingBottom={4} margin={20}>
9 <Typography variant="alpha">Welcome To Content Publisher</Typography>
10 <Box>
11 <Typography variant="epsilon">Publish blog posts to medium, dev.to, etc.</Typography>
12 </Box>
13 </Box>
14 </Main>
15 );
16};
17
18export { HomePage };
In the code above, we used some some components from the Strapi design system to update the content and structure of the content of our plugin home page.
The image below shows what our new plugin home page and icon look like after the update.
Home page of Content Publisher custom plugin
Since we want our custom plugin to be able to publish posts to Medium and Dev, we need to keep track of posts that have been published to these platforms or are ready for publishing.
We need to create a new collection type for our plugin. It will be called Post. It will have the following fields:
mediumLink
: This will be used to store the link to the published post on Medium. It will be of type Text.devToLink
: This will be used to store the link to the published post on Dev. It will be of type Text.blog
: This will point to blog content from the Blog collection type in our Strapi backend. It will be a one-to-one relationship with the Blog collection type.To generate the collection type for our plugin, we can do it in two ways. You can learn more by checking out how to store and access data from a Strapi plugin.
generate
command as shown below:1# npm
2npm run strapi generate content-type
post.ts
inside server/src/content-types
directory, and add the following code1// content-types/post.ts
2
3const schema = {
4 kind: 'collectionType',
5 collectionName: 'posts',
6 info: {
7 singularName: 'post',
8 pluralName: 'posts',
9 displayName: 'Post',
10 },
11 options: {
12 draftAndPublish: false,
13 },
14 pluginOptions: {
15 'content-manager': {
16 visible: true,
17 },
18 'content-type-builder': {
19 visible: true,
20 },
21 },
22 attributes: {
23 mediumLink: {
24 type: 'text',
25 }
26 },
27};
28
29export default {
30 schema,
31};
Below is an explanation of the code above:
We define a Strapi content type schema for Post and specify it as a collection type. We also set up basic metadata, disabled draft and publish functionality, and ensured the content type was visible in both the Content Manager and Content-Type Builder plugins of the Strapi admin panel.
We add attribute "mediumLink" as a text field. We will add the remaining fields devToLink
and blog
soon using the Content-type Builder on the Strapi admin panel.
Next, import this new content type so you can see it on the Strapi admin panel. Locate the ./src/plugins/content-publisher/server/src/content-types/index.ts
and update it with the following code:
1import post from './post';
2
3export default {
4 post,
5};
Now, we should be able to see the Post collection with the mediumLink
field for our plugin:
Post collection with mediumLink
Add the devToLink
and blog
fields by clicking the "Add another field to the collection type". Remember to make sure that the blog
field of the Post collection type is related to Blog collection in a one-to-one relation as "Post has and belongs to one Blog." See image below:
Relation between Post and Blog collection types
The Post collection type for our plugin is ready; we have to customize the backend of our plugin by creating routes, controllers, and services.
./src/plugins/content-publisher/server/src/routes/index.ts
./src/plugins/content-publisher/server/src/controllers/controller.ts
./src/plugins/content-publisher/server/src/services/service.ts
.Here is an example:
1// route GET route to fetch blogs
2export default [
3 {
4 method: 'GET',
5 path: '/blogs',
6 // name of the controller file & the method.
7 handler: 'controller.getBlogs',
8 config: {
9 policies: [],
10 },
11 },
12];
13
14// controller with a controller method or action
15const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
16 async getBlogs(ctx) {
17 ctx.body = await strapi.plugin('medium-publisher').service('service').getBlogPosts(); // invokes `getBlogposts()` service
18 },
19});
20
21// A reusable service called by controller above
22const service = ({ strapi }: { strapi: Core.Strapi }) => ({
23 // GET Blog Posts
24 async getBlogPosts() {
25 try {
26 const blogPost = await strapi.documents('api::blog.blog').findMany();
27 return blogPost;
28 } catch (error) {
29 throw error;
30 }
31 },
32});
The above is a route that handles requests to /blogs
. It is handled by a controller method called getBlogs
. This method on the other hand invokes the getBlogPosts
service.
In order to proceed, we need to customize our Strapi plugin server. Hence, we will customize the routes and controllers.
Strapi plugins can have two kinds of routes.
type:'content-api'
to the route. An example is /api/blogs
.type:'admin'
to the route. An example is plugin-name/posts
.Inside the ./src/plugins/content-publisher/server/src/routes
, create two files, admin.ts
and contentApi.ts
respectively and add the codes below for each.
Create Private Routes for Plugin
Inside the ./src/plugins/content-publisher/server/src/routes/admin.ts
file, add the following code. The following are routes accessible only to the admin panel and not the general or external API.
1// Path: ./src/plugins/content-publisher/server/src/routes/admin.ts
2
3import policies from 'src/policies';
4
5export default [
6 {
7 method: 'GET',
8 path: '/posts',
9 handler: 'controller.getPosts',
10 config: {
11 policies: [],
12 auth: false,
13 },
14 },
15 {
16 method: 'GET',
17 path: '/single-post',
18 handler: 'controller.getSinglePost',
19 config: {
20 policies: [],
21 auth: false,
22 },
23 },
24 {
25 method: 'POST',
26 path: '/publish-to-medium',
27 handler: 'controller.publishPostToMedium',
28 config: {
29 policies: [],
30 auth: false,
31 },
32 },
33 {
34 method: 'POST',
35 path: '/publish-to-devto',
36 handler: 'controller.publishPostToDevTo',
37 config: {
38 policies: [],
39 auth: false,
40 },
41 },
42 {
43 method: 'GET',
44 path: '/search',
45 handler: 'controller.getSearchQuery',
46 config: {
47 policies: [],
48 auth: false,
49 },
50 },
51 {
52 method: 'DELETE',
53 path: '/delete-post',
54 handler: 'controller.deletePost',
55 config: {
56 policies: [],
57 auth: false,
58 },
59 },
60];
In the code above, we created an array of admin routes that our plugin will use. Note that we added auth=false
. This means the routes will be publicly accessible without requiring any authentication.
🖐️ NOTE: We added
auth=false
to each route above because we want the routes to be accessible without requiring any authentication.
Here is what each route above does:
Method | Path | Handler | Description |
---|---|---|---|
GET | /posts | controller.getPosts | Retrieves a list of posts |
GET | /single-post | controller.getSinglePost | Retrieves a single post |
POST | /publish-to-medium | controller.publishPostToMedium | Publishes a post to Medium |
POST | /publish-to-devto | controller.publishPostToDevTo | Publishes a post to Dev |
GET | /search | controller.getSearchQuery | Performs a search query |
DELETE | /delete-post | controller.deletePost | Deletes a post |
The routes above will be available to our plugin admin panel on the endpoint /content-publisher
which is the name of our plugin. For example /content-publisher/posts
.
We will create the corresponding controllers for each of these routes shortly.
Create Public Route In order to demonstrate that we can make some routes private to the admin panel and others accessible externally, we will create a publicly availabe route.
Inside the ./src/plugins/content-publisher/server/src/routes/contentApi.ts
file, add the following:
1export default [
2 {
3 method: 'GET',
4 path: '/blogs',
5 // name of the controller file & the method.
6 handler: 'controller.getBlogs',
7 config: {
8 policies: [],
9 },
10 },
11];
This will be publicly accessible on api/blogs
as we will see when testing our routes.
Import Private and Public Routes
Head over to ./src/plugins/content-publisher/server/src/routes/index.ts
and import the routes we created above.
1'use strict';
2
3import admin from './admin';
4import contentApi from './contentApi';
5
6export default {
7 'content-api': {
8 type: 'content-api',
9 routes: [...contentApi],
10 },
11 admin: {
12 type: 'admin',
13 routes: [...admin],
14 },
15};
As seen in the code above, we have routes that should be accessible only on the admin panel. We specified this with the type: 'admin'
. For the route that is available to the general API router, we specified type: 'content-api'
.
At this point, our app should break because the controllers we mentioned in the routes above haven't been created. Let's create them.
Update the controller file in ./src/plugins/content-publisher/server/src/controllers/controller.ts
with the following code:
1// Path: ./src/plugins/content-publisher/server/src/controllers/controller.ts
2
3import type { Core } from '@strapi/strapi';
4
5const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
6 // get blog entries
7 async getBlogs(ctx) {},
8
9 // get posts
10 async getPosts(ctx) {},
11
12 // publish a blog post to medium
13 async publishPostToMedium(ctx) {},
14
15 // publish a blog post to dev.to
16 async publishPostToDevTo(ctx) {},
17
18 // search for a post
19 async getSearchQuery(ctx) {},
20
21 // delete a post
22 async deletePost(ctx) {},
23
24 // get a single post
25 async getSinglePost(ctx) {},
26});
27
28export default controller;
In the code above, we added the controller methods, which we will update and use later on in this tutorial.
Here is what each of the controller methods do:
getBlogs
: This will help us fetch blogs that have been created.getPosts
: This will help us fetch posts for publishing.publishPostToMedium
: We will use this to publish a post to Medium.publishPostToDevTo
: With this, we can publish a post to Dev.getSearchQuery
: When we send a query request to fetch a post, this method will return the search result.getSinglePost
: This will allow us to get a single post.Inside the root of your project folder, locate the .env
file and add the following environment variables to the existing ones:
MEDIUM_API_TOKEN="your medium api key"
MEDIUM_USER_ID="your medium user id"
DEVTO_API_KEY="your dev.to api key"
In the code above, we created environment variables for the Medium and Dev APIs. The Medium API requires a user ID and an API token. Meanwhile, the Dev API requires an API key.
Let's obtain these credentials.
Before we continue with the next sections, we need to have some credentials in order to consume the Medium and Dev APIs.
To obtain your Dev API key, follow the following instructions below:
Generate Dev API Key
Generate a new key and add the value to the environment variable DEVTO_API_KEY
.
Obtain your Medium API key by following the instructions below:
This will open a modal for you to copy your API key. Copy Medium API Key
Get the token above and add it as a value to the environment variable MEDIUM_API_TOKEN
.
With the API token above, we can now get the value for MEDIUM_USER_ID
. Hence, to obtain your user ID, make a GET
request to https://api.medium.com/v1/me
. Ensure you add an authorization header to the request, which should be your API token.
Here is an example below
1GET /v1/me HTTP/1.1
2Host: api.medium.com
3Authorization: Bearer 181d415f34379af07b2c11d144dfbe35d
4Content-Type: application/json
5Accept: application/json
6Accept-Charset: utf-8
Using Postman HTTP client:
Add API Key to request
Here is a successful response: Successful Response
In the response above, the user ID, represented by id
, is returned along with other user details. Get the user ID and add it as a value to the MEDIUM_USER_ID
environment variable.
When we create a blog entry, we want to automatically create a corresponding post entry. As we said before, a post entry will contain details about the publishing of the blog entry, like the live Medium and Dev links, and relation to the blog entry from which it was created.
Navigate to the register lifecycle function file ./src/plugins/content-publisher/server/src/register.ts
and replace the code inside with the following:
1// Path: ./src/plugins/content-publisher/server/src/register.ts
2import type { Core } from '@strapi/strapi';
3
4const register = ({ strapi }: { strapi: Core.Strapi }) => {
5 strapi.db.lifecycles.subscribe({
6 // only for the blog collection type
7 models: ['api::blog.blog'],
8 // after a blog post is created
9 async afterCreate(event) {
10 // create new data
11 const newData = {
12 blog: event.result.documentId,
13 mediumLink: null,
14 devToLink: null,
15 };
16
17 // create new post
18 await strapi.documents('plugin::medium-publisher.post').create({
19 data: newData,
20 });
21 },
22 });
23};
24
25export default register;
The code above is a Strapi 5 lifecycle function that listens for events related to the Blog API collection type models: ['api::blog.blog']
. Specifically, it subscribes to the afterCreate
event, which triggers after a new blog entry is created.
When triggered, it creates a new entry in the content-publisher.post
, which is the Post collection type of our plugin, with references to the blog post and placeholder values for mediumLink
and devToLink
Create a new Blog Entry Let's see if our lifecycle event works. Start by creating a new blog entry.
Create a Blog Entry
After creating the blog entry, navigate to the Post collection, and you will find a new entry automatically created upon creating the blog entry.
A Post Entry is Automatically created
As you can see, new post entries are created automatically.
Now, create more blog entries so that we can fetch and display posts for publishing.
Posts have now been created. Let's display them on the admin panel of our plugin.
Do you recall the getPosts()
controller method we created some time ago inside the controller file ./src/plugins/content-publisher/server/src/controllers/controller.ts
, let's modify it with the following:
1...
2// get posts
3async getPosts(ctx) {
4 ctx.body = await strapi
5 .plugin("content-publisher")
6 .service("service")
7 .getPosts();
8}
The controller method above retrieves posts by calling the getPosts()
service, which we will create shortly, of our plugin "content-publisher", and sets the response body ctx.body
to the result.
Inside the ./src/plugins/content-publisher/admin/src/components
directory, create a file called PublishingTable.tsx
, and add the following code inside with the following:
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3import {
4 Table,
5 Thead,
6 Tbody,
7 Tr,
8 Td,
9 Th,
10 Typography,
11 Box,
12 Link,
13 Flex,
14} from '@strapi/design-system';
15
16import { Trash } from '@strapi/icons';
17
18import axios from 'axios';
19import { useState, useEffect, FormEvent } from 'react';
20
21import formattedDate from '../utils/formattedDate';
22import PublishButton from './PublishButton';
23import MediumIcon from './MediumIcon';
24import DevToIcon from './DevToIcon';
25
26const PublishingTable = () => {
27 const [posts, setPosts] = useState([]);
28
29 return (
30 <Box>
31 <Box padding={8} margin={20}>
32 <Table colCount={7} rowCount={posts.length + 1}>
33 <Thead>
34 <Tr>
35 <Th>
36 <Typography variant="sigma" textColor="neutral600">
37 Blog ID
38 </Typography>
39 </Th>
40 <Th>
41 <Typography variant="sigma" textColor="neutral600">
42 Date Created
43 </Typography>
44 </Th>
45 <Th>
46 <Typography variant="sigma" textColor="neutral600">
47 Blog Title
48 </Typography>
49 </Th>
50 <Th>
51 <Typography variant="sigma" textColor="neutral600">
52 Blog Link
53 </Typography>
54 </Th>
55 <Th>
56 <Flex gap={2} direction="row" alignItems="center">
57 {/* Medium icon */}
58 <MediumIcon />
59 <Typography variant="sigma">Medium</Typography>
60 </Flex>
61 </Th>
62 <Th>
63 <Flex gap={2} direction="row" alignItems="center">
64 {/* Dev.to icon */}
65 <DevToIcon />
66 <Typography variant="sigma">Dev.to</Typography>
67 </Flex>
68 </Th>
69 <Th></Th>
70 </Tr>
71 </Thead>
72 <Tbody>
73 {posts.map((post: any) => (
74 <Tr key={post.id}>
75 <Td>
76 <Typography textColor="neutral800">{post.id}</Typography>
77 </Td>
78 <Td>
79 <Typography textColor="neutral800">
80 {formattedDate(post.blog?.updatedAt)}
81 </Typography>
82 </Td>
83 <Td>
84 <Typography textColor="neutral800">{post.blog.title.slice(0, 30)}...</Typography>
85 </Td>
86 <Td>
87 <Typography textColor="neutral800">
88 <Link
89 href={`http://localhost:1337/admin/content-manager/collection-types/api::blog.blog/${post.blog.documentId}`}
90 >
91 {post.blog.title.slice(0, 30)}...
92 </Link>
93 </Typography>
94 </Td>
95 <Td>
96 <PublishButton post={post} type="medium" />
97 </Td>
98 <Td>
99 <PublishButton post={post} type="devto" />
100 </Td>
101 <Td>
102 <Trash style={{ cursor: 'pointer', color: 'red' }} width={20} height={20} />
103 </Td>
104 </Tr>
105 ))}
106 </Tbody>
107 </Table>
108 </Box>
109 </Box>
110 );
111};
112
113export default PublishingTable;
The PublishingTable
React component above renders a table to manage and display posts. It uses the @strapi/design-system
library for UI components and displays blog data such as ID, creation date, title, and links, along with publishing options for Medium and Dev.to via PublishButton
component which we will create soon. It also contains components MediumIcon
and DevToIcon
which we will create soon.
The table dynamically maps over a posts state array (managed by useState
) to populate rows, and includes a utility function formattedDate()
(we will create this shortly) that format dates and a delete button represented by a trash icon.
So, let's create the PublishButton
component and the formattedDate()
utility function.
By default, Strapi returns dates in ISO 8601 format, e.g. 2025-01-06T22:09:08.655Z. However, we want to display the dates in a much friendlier way, like January 6, 2025.
Inside the ./src/plugins/content-publisher/admin/src/utils
directory, create a file called formattedDate.ts
and add the following code:
1// Path: ./src/plugins/content-publisher/admin/src/utils/formattedDate.ts
2
3const formattedDate = (isoDate: Date) => {
4 // Create a Date object from the ISO string
5 const date = new Date(isoDate);
6
7 const result = date.toLocaleDateString('en-US', {
8 year: 'numeric',
9 month: 'long',
10 day: 'numeric',
11 });
12
13 return result;
14};
15
16export default formattedDate;
The formattedDate()
function converts an ISO date string such as "2025-01-06T22:09:08.655Z" into a human-readable format (e.g., "January 1, 2025") using the toLocaleDateString
method with U.S. English formatting.
In the PublishingTable
component we created above, we imported PublishButton
, MediumIcon
and DevToIcon
. Let's create them.
Create Publish Button Component
Inside the ./src/plugins/content-publisher/admin/src/components
directory, create a new file called PublishButton.tsx
and add the following code. This component will be responsible for publishing posts to different platforms.
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishButton.tsx
2
3import { Box, Button, Typography, LinkButton, Flex, Link } from '@strapi/design-system';
4import { Play, Check, Cursor } from '@strapi/icons';
5
6const PublishButton = ({ post, type }: { post: any; type: string }) => {
7 return (
8 <Box>
9 <Button style={bigBtn} size="S" startIcon={<Play />} variant="default">
10 <Typography variant="pi">start</Typography>
11 </Button>
12 </Box>
13 );
14};
15
16const bigBtn = {
17 width: '100px',
18};
19
20export default PublishButton;
It takes two parameters, the Post entry and the type "medium" or "devto".
Create Medium Icon Component
Inside the ./src/plugins/content-publisher/admin/src/components
directory, create a new file called MediumIcon.tsx
and add the following code:
1// Path: ./src/plugins/content-publisher/admin/src/components/MediumIcon.tsx
2
3import React from "react";
4
5export default function MediumIcon() {
6 return (
7 <svg
8 width="24px"
9 height="24px"
10 viewBox="0 0 24 24"
11 fill="none"
12 xmlns="http://www.w3.org/2000/svg"
13 >
14 <g id="SVGRepo_bgCarrier" stroke-width="0"></g>
15 <g
16 id="SVGRepo_tracerCarrier"
17 stroke-linecap="round"
18 stroke-linejoin="round"
19 ></g>
20 <g id="SVGRepo_iconCarrier">
21 {" "}
22 <path
23 d="M13 12C13 15.3137 10.3137 18 7 18C3.68629 18 1 15.3137 1 12C1 8.68629 3.68629 6 7 6C10.3137 6 13 8.68629 13 12Z"
24 fill="#0F0F0F"
25 ></path>{" "}
26 <path
27 d="M23 12C23 14.7614 22.5523 17 22 17C21.4477 17 21 14.7614 21 12C21 9.23858 21.4477 7 22 7C22.5523 7 23 9.23858 23 12Z"
28 fill="#0F0F0F"
29 ></path>{" "}
30 <path
31 d="M17 18C18.6569 18 20 15.3137 20 12C20 8.68629 18.6569 6 17 6C15.3431 6 14 8.68629 14 12C14 15.3137 15.3431 18 17 18Z"
32 fill="#0F0F0F"
33 ></path>{" "}
34 </g>
35 </svg>
36 );
37}
Create Dev Icon Component
Inside the ./src/plugins/content-publisher/admin/src/components
directory, create a new file called MediumIcon.tsx
and add the following code:
1// Path: ./src/plugins/content-publisher/admin/src/components/MediumIcon.tsx
2
3import React from "react";
4
5export default function DevToIcon() {
6 return (
7 <svg
8 xmlns="http://www.w3.org/2000/svg"
9 aria-label="dev.to"
10 role="img"
11 viewBox="0 0 512 512"
12 width="24px"
13 height="24px"
14 fill="#000000"
15 >
16 <g id="SVGRepo_bgCarrier" stroke-width="0"></g>
17 <g
18 id="SVGRepo_tracerCarrier"
19 stroke-linecap="round"
20 stroke-linejoin="round"
21 ></g>
22 <g id="SVGRepo_iconCarrier">
23 <rect width="512" height="512" rx="15%"></rect>
24 <path
25 fill="#ffffff"
26 d="M140.47 203.94h-17.44v104.47h17.45c10.155-.545 17.358-8.669 17.47-17.41v-69.65c-.696-10.364-7.796-17.272-17.48-17.41zm45.73 87.25c0 18.81-11.61 47.31-48.36 47.25h-46.4V172.98h47.38c35.44 0 47.36 28.46 47.37 47.28zm100.68-88.66H233.6v38.42h32.57v29.57H233.6v38.41h53.29v29.57h-62.18c-11.16.29-20.44-8.53-20.72-19.69V193.7c-.27-11.15 8.56-20.41 19.71-20.69h63.19zm103.64 115.29c-13.2 30.75-36.85 24.63-47.44 0l-38.53-144.8h32.57l29.71 113.72 29.57-113.72h32.58z"
27 ></path>
28 </g>
29 </svg>
30 );
31}
Implement a function to fetch posts. Add the following code to the PublishingTable
component.
1// ... other codes
2
3const handleFetchPosts = async () => {
4 try {
5 // Get posts from content-publisher plugin
6 const response = await axios.get(`/content-publisher/posts`);
7 setPosts(response.data);
8 } catch (error) {
9 console.error("Error fetching posts:", error);
10 }
11};
12
13useEffect(() => {
14 handleFetchPosts();
15}, []);
16
17// ... other codes
The handleFetchPosts()
function above fetches blog posts from the /content-publisher/posts
endpoint which we already created in our routes, using axios and updates the posts state with the response data, handling any errors by logging them to the console. It is invoked inside a useEffect
hook to run once when the component mounts.
Here is what our plugin admin panel should look like:
Content Publisher Table
👉 See the complete code of PublishingTable
component here.
Now, let's publish a post to Dev!
Let's start by implementing this on the server side of our plugin.
1. Create a Service For Publishing Post to Dev
We will start by creating a service called publishPostToDevTo
. Add the code below to the service file ./src/plugins/content-publisher/server/src/services/service.ts
with the following code:
1// ... other codes
2
3/**
4 * Publish Post to Dev.to
5 */
6async publishPostToDevTo(post: any) {
7 try {
8 // destructuring the post object
9 const { title, content, canonicalUrl, tags, banner } = post.blog;
10 // get the blog tags
11 const blogTags = tags.map((tag) => tag.blogTag);
12
13 // payload to be sent to dev.to
14 const devToPayload = {
15 article: {
16 title,
17 body_markdown: content,
18 published: true,
19 series: null,
20 main_image: banner,
21 canonical_url: canonicalUrl,
22 description:
23 content.length > 140 ? `${content.slice(0, 140)}...` : content,
24 tags: blogTags,
25 organization_id: null,
26 },
27 };
28
29 // post
30 const response = await axios.post(
31 `https://dev.to/api/articles`,
32 devToPayload,
33 {
34 headers: {
35 "Content-Type": "application/json",
36 "api-key": process.env.DEVTO_API_KEY,
37 },
38 },
39 );
40
41 // get the dev.to url
42 const devToUrl = response.data?.url;
43
44 // update the post with the dev.to link
45 await strapi.documents("plugin::content-publisher.post").update({
46 documentId: post.documentId,
47 data: {
48 devToLink: devToUrl,
49 } as any,
50 });
51
52 // return the response
53 return response.data;
54 } catch (error) {
55 return error.response.data
56 }
57}
58
59// ... other codes
👉 See complete code of the snippet above
Here is what the code above does:
title
, content
, canonicalUrl
, tags
, and bannerPOST
request to https://dev.to/api/articles
endpoint with the Dev API Key.2. Update Plugin Controller
Update the plugin controller method publishPostToDevTo
with the following code:
1// ./src/plugins/content-publisher/server/src/controllers/controller.ts
2
3import type { Core } from "@strapi/strapi";
4
5const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
6 // get blog entries
7 async getBlogs(ctx) {},
8
9 // get posts
10 async getPosts(ctx) {
11 ctx.body = await strapi
12 .plugin("content-publisher")
13 .service("service")
14 .getPosts();
15 },
16
17 // publish a blog post to dev.to
18 async publishPostToDevTo(ctx) {
19 ctx.body = await strapi
20 .plugin("medium-publisher")
21 .service("service")
22 .publishPostToDevTo(ctx.request.body);
23 },
24
25 // publish a blog post to medium
26 async publishPostToMedium(ctx) {},
27
28 // search for a post
29 async getSearchQuery(ctx) {},
30
31 // delete a post
32 async deletePost(ctx) {},
33
34 // get a single post
35 async getSinglePost(ctx) {},
36});
37
38export default controller;
The publishPostToDevTo
controller method is now modified to call the publishPostToDevTo
service we created earlier.
In the admin section, we will have to update the publish button to be able to publish post to Dev or Medium.
Update The Publish Button
1// ... other codes
2
3// Function to handle publishing the post
4 const handlePublishPost = async () => {
5 try {
6 // Set loading to true
7 setLoading(true);
8 let endpoint;
9
10 // Check if the type is medium or devto
11 if (type === 'medium') {
12 endpoint = '/content-publisher/publish-to-medium';
13 } else {
14 endpoint = '/content-publisher/publish-to-devto';
15 }
16
17 // Post the data
18 await axios.post(endpoint, post);
19
20 // Reload the page
21 window.location.reload();
22 } catch (error) {
23 console.log(error);
24 } finally {
25 // Set loading to false
26 setLoading(false);
27 }
28 };
29
30// ... other codes
👉 See complete code of the snippet above.
Here is what the code above does:
PublishButton
button to handle publishing a post to either Medium or Dev.to based on the type
prop. useState
and uses an axios.post
request to send the post data to the appropriate plugin endpoint /content-publisher/publish-to-medium
or /content-publisher/publish-to-devto
. Recall the private routes we created earlier for our plugin.Here is a demo of what we did above:
As seen above, we can now publish posts to Dev. Amazing!
You can find the complete project for this tutorial in this Github repo.
In the repo, you will find branches part-1
and part-2
. They both represent the complete code for each part of this tutorial.
In this tutorial, we learned how to develop Strapi plugins. We have delved into initializing and customizing a Strapi plugin. We also looked at how to create content types for a Strapi plugin and pass data in a Strapi plugin. Finally, we consumed the Dev API using our Strapi headless CMS plugin to publish posts to dev.to.
In the next part of this tutorial, we will be able to publish posts to medium.com. Also, we will inject our plugin into the Blog collection type and implement search and pagination. See you in the next part.
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
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.