In the previous part of this tutorial, we learned about how to develop Strapi plugins. We also delved into initializing and customizing a Strapi plugin. Furthermore, we looked at how to create content types for a Strapi plugin and pass data in a Strapi plugin. Finally, we consumed the Dev.to API using our Strapi plugin to publish posts to Dev.
This is the final part, where you will be able to publish posts to Medium, add pagination and search features.
This article is in two parts:
With Strapi headless CMS, we can do much more. Let's publish a post to Medium from Strapi CMS!
1. Create a Service for Publishing to Medium
We will create a service that will help us publish posts to Medium. Locate the service file ./src/plugins/content-publisher/server/src/services/service.ts
and add a new service method called publishPostToMedium
as shown below:
1// Path: ./src/plugins/content-publisher/server/src/services/service.ts
2
3// ...other codes
4
5/**
6 * Publish Post to Medium
7 */
8async publishPostToMedium(post: any) {
9 try {
10 // destructuring the post object
11 const { title, content, canonicalUrl, tags, banner } = post.blog;
12 // get the blog tags
13 const blogTags = tags.map((tag) => tag.blogTag);
14
15 // payload to be sent to medium
16 const mediumPayload = {
17 title,
18 content: `${title}\n![Image banner](${banner})\n${content}`,
19 canonicalUrl,
20 tags: blogTags,
21 contentFormat: "markdown",
22 };
23
24 // post
25 const response = await axios.post(
26 `https://api.medium.com/v1/users/${process.env.MEDIUM_USER_ID}/posts`,
27 mediumPayload,
28 {
29 headers: {
30 Authorization: `Bearer ${process.env.MEDIUM_API_TOKEN}`,
31 "Content-Type": "application/json",
32 },
33 },
34 );
35
36 // get the medium url
37 const mediumUrl = response.data?.data?.url;
38
39 // update the post with the medium link
40 await strapi.documents("plugin::content-publisher.post").update({
41 documentId: post.documentId,
42 data: {
43 mediumLink: mediumUrl,
44 } as Partial<any>,
45 });
46
47 // return the response
48 return response.data;
49 } catch (error) {
50 throw error;
51 }
52}
👉 See complete code of the snippet above
Similar to publishPostToDevTo
, the publishPostToMedium
function above does the following:
post.blog
object, including title
, content
, canonicalUrl
, tags
, and banner
.strapi.documents
service for the "plugin::content-publisher.post
content-type. It provides the documentId
of the post to update. In the data object, the mediumLink
field is set to the URL of the newly created Medium post.2. Update publishPostToMedium
Controller
Update the plugin controller method publishPostToMedium
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 ctx.body = await strapi.plugin('content-publisher').service('service').getPosts();
12 },
13
14 // publish a blog post to dev.to
15 async publishPostToDevTo(ctx) {
16 ctx.body = await strapi
17 .plugin('content-publisher')
18 .service('service')
19 .publishPostToDevTo(ctx.request.body);
20 },
21
22 // publish a blog post to medium
23 async publishPostToMedium(ctx) {
24 ctx.body = await strapi
25 .plugin('content-publisher')
26 .service('service')
27 .publishPostToMedium(ctx.request.body);
28 },
29
30 // search for a post
31 async getSearchQuery(ctx) {},
32
33 // delete a post
34 async deletePost(ctx) {},
35
36 // get a single post
37 async getSinglePost(ctx) {},
38});
39
40export default controller;
The publishPostToMedium
controller method is now modified to call the publishPostToMedium
service we created above.
Now, go ahead and publish a post to Medium.
So far, we have only been able to publish a post from the home page of our plugin. In this section, let's demonstrate the use of injection zones in a Strapi plugin.
The injection zone feature allows plugins to add custom UI elements to specific areas of the Strapi admin panel. In this case, our content-publisher
plugin will add a component to the right side of the edit view in the Content Manager. This is so we can publish directly from the Blog content manager.
Here is what it should look like:
Injected component
As shown in the image above, we injected a component to help us publish a blog directly in the content manager.
We will start by creating the component we want to inject into the component manager.
Mind you, we only want this component to be visible on the Blog collection type. So, we will need to use the unstable_useContentManagerContext
hook, which is a replacement for the useCMEditViewDataManager. This hook accesses data and functionality related to the Edit View in the Content Manager such as the ID and slug of the content type.
Inside the ./src/plugins/content-publisher/admin/src/components
folder, create the file InjectedComponent.tsx
and add the following code:
1// Path: ./src/plugins/content-publisher/admin/src/components/InjectedComponent.tsx
2
3import React from 'react';
4import { Box, Flex, Typography, Divider } from '@strapi/design-system';
5import { unstable_useContentManagerContext } from '@strapi/strapi/admin';
6
7import axios from 'axios';
8import { useEffect, useState } from 'react';
9import PublishButton from './PublishButton';
10import MediumIcon from './MediumIcon';
11import DevToIcon from './DevToIcon';
12
13export default function InjectedComponent() {
14 // get the blog id
15 const { slug, id } = unstable_useContentManagerContext();
16 const [post, setPost] = useState({
17 mediumLink: '',
18 devToLink: '',
19 blog: null,
20 });
21
22 // fetch single post
23 const fetchSinglePost = async () => {
24 const post = await axios.get(`/content-publisher/single-post?blogId=${id}`);
25 setPost(post.data);
26 };
27
28 // fetch single post
29 useEffect(() => {
30 fetchSinglePost();
31 }, []);
32
33 // check if the slug is not blog
34 if (slug !== 'api::blog.blog') return null;
35
36 return (
37 <Box>
38 {post ? (
39 <>
40 <Typography variant="beta" padding={30}>
41 Publish to:
42 </Typography>
43 <Box marginTop={5}>
44 <Flex
45 gap={{
46 large: 2,
47 }}
48 direction={{
49 initial: 'row',
50 }}
51 alignItems={{
52 initial: 'center',
53 }}
54 >
55 <Typography variant="sigma">Medium</Typography>
56 </Flex>
57
58 <PublishButton post={post} type="medium" />
59 <Divider />
60 </Box>
61
62 <Box padding={30}>
63 <Divider marginBottom={4} />
64 <Flex
65 gap={{
66 large: 2,
67 }}
68 direction={{
69 initial: 'row',
70 }}
71 alignItems={{
72 initial: 'center',
73 }}
74 >
75 <Typography variant="sigma">Dev.to</Typography>
76 </Flex>
77 <PublishButton post={post} type="devto" />
78 </Box>
79 </>
80 ) : null}
81 </Box>
82 );
83}
Let's break down the code above:
unstable_useContentManagerContext
from Strapi's admin panel which retrieves the id
and slug
of the blog entry./content-publisher/single-post
, which we created in the first part of this tutorial series. We will soon create the service and update the controller for this. It sends a request by adding the parameter blogId=${id}
. Recall that the id
was gotten using the unstable_useContentManagerContext()
hook.slug
as api::blog.blog
, which corresponds to Blog collection type.PublishButton
, which accepts the post we fetched as a parameter. This will allow us to be able to publish to Dev and Medium from the Blog collection type.Update getSinglePost
Controller Method
Do you recall the getSinglePost
controller method we created in Part 1 of this tutorial that should be called when we make a GET
request to the /single-post
endpoint? We will update it 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 ctx.body = await strapi.plugin('content-publisher').service('service').getPosts();
12 },
13
14 // publish a blog post to dev.to
15 async publishPostToDevTo(ctx) {
16 ctx.body = await strapi
17 .plugin('content-publisher')
18 .service('service')
19 .publishPostToDevTo(ctx.request.body);
20 },
21
22 // publish a blog post to medium
23 async publishPostToMedium(ctx) {
24 ctx.body = await strapi
25 .plugin('content-publisher')
26 .service('service')
27 .publishPostToMedium(ctx.request.body);
28 },
29
30 // get a single post
31 async getSinglePost(ctx) {
32 ctx.body = await strapi
33 .plugin('content-publisher')
34 .service('service')
35 .getSinglePost(ctx.request.query);
36 },
37 // search for a post
38 async getSearchQuery(ctx) {},
39
40 // delete a post
41 async deletePost(ctx) {},
42});
43
44export default controller;
In the code above:
strapi.plugin('content-publisher')
accesses the "content-publisher" plugin..service('service')
retrieves the main service of the plugin.getSinglePost(ctx.request.query)
calls the getSinglePost
method of the service, which we will create soon, passing in the query parameters from the request. Recall that the query parameter is the blog ID (blogId=${id}
) as explained in the injected component above.ctx.body
, which sets the response body for the HTTP request.Create getSinglePost
Service
Inside the the service file, create the getSinglePost
service method.
1// ./src/plugins/content-publisher/server/src/services/service.ts
2
3// ... other codes
4
5/**
6 * FETCH SINGLE Post
7 */
8async getSinglePost(query: any) {
9 // get blogId from query
10 const { blogId } = query;
11 try {
12 // find the post
13 const post = await strapi.documents('plugin::content-publisher.post').findFirst({
14 populate: {
15 blog: {
16 populate: ['tags'],
17 },
18 },
19 // filter the post by blogId
20 filters: {
21 blog: {
22 documentId: {
23 $eq: blogId,
24 },
25 },
26 },
27 });
28
29 // return the post
30 return post;
31 } catch (error) {
32 throw error;
33 }
34}
35
36
37// ... other codes
👉 See complete code of the snippet above.
Here is what the code above does:
getSinglePost
method takes a query parameter of type any.blogId
from the query parameter.
The main functionality is wrapped in a try-catch block for error handling.strapi.documents('plugin::medium-publisher.post')
accesses the Post content-type of the 'content-publisher' plugin.findFirst()
is used to find the first document matching the given criteria. It populates the 'blog'
field and its 'tags'
. It looks for a post where the related blog's documentId
equals the provided blogId
using the $eq
operator. Learn more about filtering.Inside the ./src/plugins/content-publisher/admin/src/index.ts
file, which is the Admin Panel API entry file, register an injection zone:
1// ./src/plugins/content-publisher/admin/src/index.ts
2
3// from this
4app.registerPlugin({
5 id: PLUGIN_ID,
6 initializer: Initializer,
7 isReady: false,
8 name: PLUGIN_ID,
9});
10
11
12// to this
13app.registerPlugin({
14 id: PLUGIN_ID,
15 initializer: Initializer,
16 isReady: false,
17 name: PLUGIN_ID,
18 injectionZones: {
19 editView: {
20 "right-links": [],
21 },
22 },
23});
We modified the registerPlugin
function by declaring an injection zone named 'right-links' in the 'editView' of the Content Manager. It's initially empty (an empty array). This will allow us to inject the component on the right side of the Blog content manager.
bootstrap()
LifecycleLet's inject the InjectedComponent
into the 'right-links' zone of the 'editView' using the bootstrap
function.
1async bootstrap(app: any) {
2 app.getPlugin("content-manager").injectComponent("editView", "right-links", {
3 name: "content-publisher",
4 Component: () => <InjectedComponent />,
5 });
6}
This injects the InjectedComponent
into the 'right-links' zone of the 'editView'. The InjectedComponent
component will now be rendered in the injection zone.
Here is the full code of our modified Admin Panel API entry file:
1// Path: ./src/plugins/content-publisher/admin/src/index.ts
2
3import React from 'react';
4import { getTranslation } from './utils/getTranslation';
5import { PLUGIN_ID } from './pluginId';
6import { Initializer } from './components/Initializer';
7import { PluginIcon } from './components/PluginIcon';
8import InjectedComponent from './components/InjectedComponent';
9
10export default {
11 register(app: any) {
12 app.addMenuLink({
13 to: `plugins/${PLUGIN_ID}`,
14 icon: PluginIcon,
15 intlLabel: {
16 id: `${PLUGIN_ID}.plugin.name`,
17 defaultMessage: PLUGIN_ID,
18 },
19 Component: async () => {
20 const { App } = await import('./pages/App');
21
22 return App;
23 },
24 });
25
26 app.registerPlugin({
27 id: PLUGIN_ID,
28 initializer: Initializer,
29 isReady: false,
30 name: PLUGIN_ID,
31 injectionZones: {
32 editView: {
33 'right-links': [],
34 },
35 },
36 });
37 },
38
39 async bootstrap(app: any) {
40 app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
41 name: 'content-publisher',
42 Component: () => <InjectedComponent />,
43 });
44 },
45
46 async registerTrads({ locales }: { locales: string[] }) {
47 return Promise.all(
48 locales.map(async (locale) => {
49 try {
50 const { default: data } = await import(`./translations/${locale}.json`);
51
52 return { data, locale };
53 } catch {
54 return { data: {}, locale };
55 }
56 })
57 );
58 },
59};
Now, we can publish content directly from the Blog content manager of the admin panel.
🖐️ NOTE: If you can't get your component to be displayed. You can follow the instructions below.
./src/plugins/content-publisher/server/src/index.ts
file to ./src/plugins/content-publisher/server/src/index.tsx
- change .ts
to .tsx
. "source": "./admin/src/index.ts"
inside the package.json
file of your plugin(./src/plugins/content-publisher/package.json
) ... to "source": "./admin/src/index.tsx"
- change .ts
to .tsx
.1// ... other codes
2"exports": {
3 "./package.json": "./package.json",
4 "./strapi-admin": {
5 "types": "./dist/admin/src/index.d.ts",
6 "source": "./admin/src/index.tsx", // change ts => tsx
7 "import": "./dist/admin/index.mjs",
8 "require": "./dist/admin/index.js",
9 "default": "./dist/admin/index.js"
10 },
11// ... other codes
strapi build
. Do the same for the plugin.Restart your Strapi dev server with npm run develop
. CD into the plugin folder and start your plugin in watch mode with npm run watch
.
Now that we can publish posts to external platforms such as Medium and Dev, what happens when we have so many posts to publish. Let's add pagination in order to solve this issue.
We want to display only 5 posts per page. So, we will create the following variables:
currentPage
: Tracks the current page number.pageCount
: Total number of pages, calculated based on the total posts and posts per page.postsPerPage
: Number of posts to display per page (set to 5).We will also create the function handleFetchPosts
to calculate the start
index for the API request based on the currentPage
. Then, an API request will be made to /content-publisher/posts
with query parameter for pagination: start=${start}
.
Lastly, we will render buttons based on the pagination number.
Here is what our pagination should look like:
Plugin Pagination Tabs
getPosts
Controller Method for PaginationLet's update the getPosts
controller method to accept the parameters for pagination.
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(ctx.query);
15 },
16
17 // publish a blog post to dev.to
18 async publishPostToDevTo(ctx) {
19 ctx.body = await strapi
20 .plugin('content-publisher')
21 .service('service')
22 .publishPostToDevTo(ctx.request.body);
23 },
24
25 // publish a blog post to medium
26 async publishPostToMedium(ctx) {
27 ctx.body = await strapi
28 .plugin('content-publisher')
29 .service('service')
30 .publishPostToMedium(ctx.request.body);
31 },
32
33 // get a single post
34 async getSinglePost(ctx) {
35 ctx.body = await strapi
36 .plugin('content-publisher')
37 .service('service')
38 .getSinglePost(ctx.request.query);
39 },
40 // search for a post
41 async getSearchQuery(ctx) {},
42
43 // delete a post
44 async deletePost(ctx) {},
45});
46
47export default controller;
In the code above, we updated the getPosts
controller method to accept query parameters.
getPosts
Service for PaginationUpdate the getPosts
service method with the code below in order to return paginations.
1// ./src/plugins/content-publisher/server/src/services/service.ts
2
3// ... other codes
4
5const postPerPage = 5;
6
7// ... other codes
8
9/**
10 * GET Posts with Pagination
11 */
12 async getPosts(query: any) {
13 try {
14 // get start from query params
15 const { start } = query;
16
17 // get total posts count
18 const totalPosts = await strapi.documents('plugin::content-publisher.post').count({});
19
20 // get posts
21 const posts = await strapi.documents('plugin::content-publisher.post').findMany({
22 populate: {
23 blog: {
24 populate: ['tags'],
25 },
26 },
27 // return only 5 posts from the start index
28 start,
29 limit: postPerPage,
30 });
31
32 // return the posts and total posts
33 return { posts, totalPosts };
34 } catch (error) {
35 throw error;
36 }
37 },
38
39
40// ... other codes
👉 See complete code of the snippet above
We created a constant postPerPage
and gave it a value of 5
, the number of posts we want to see for a page. We updated the getPosts
method.
Here is what the getPosts
method does:
query
parameter, which we get the start
index for pagination.count
method and assigns the value to totalPosts
. findMany
.findMany
takes parameters populate
, which populates the blog
relation and further populates the tags
relation within the blog. It also takes parameters start
, the starting index for pagination, and parameter limit
, which is the number of posts to return (defined by the postPerPage
variable).PublishingTable
for PaginationHere, we will update the PublishingTable
component to render as a pagination.
Import Pagination Components from Strapi Design System
Locate the ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
file. We will start by importing components for pagination.
PageLink
for a page we want to visit.previousLink
for a previous page.Pagination
as parent component for all other components.1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3import {
4 // ... other components
5
6 PageLink,
7 Pagination,
8 PreviousLink,
9 NextLink,
10} from '@strapi/design-system';
11
12// ... other codes
Create Pagination Variables
We will create state variables pageCount
, currentPage
, and postsPerpage
as we mentioned earlier in our pagination logic.
1// Path: `./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx`
2
3// ... other codes
4
5const [pageCount, setPageCount] = useState(1);
6const [currentPage, setCurrentPage] = useState(1);
7
8const postsPerPage = 5;
9
10// ... other codes
Update handleFetchPosts
function
We will update the handleFetchPosts
function to make a request with the start
query parameter.
1/// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5const handleFetchPosts = async (page: number) => {
6 // Calculate the start index
7 const start = (page - 1) * postsPerPage;
8 try {
9 const response = await axios.get(
10 `/content-publisher/posts?start=${start}`,
11 );
12 setPosts(response.data.posts);
13 setPageCount(Math.ceil(response.data.totalPosts / postsPerPage));
14 } catch (error) {
15 console.error("Error fetching posts:", error);
16 }
17};
18
19// .. other codes
Create Function to Handle Page Change
We want a function to handle page changes when we switch between pagination tabs. Create the handlePageChange
function below.
1// ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5const handlePageChange = (e: FormEvent, page: number) => {
6 // Prevent the default behavior of the link
7 if (page < 1 || page > pageCount) return;
8 setCurrentPage(page);
9
10 handleFetchPosts(page);
11};
12
13// ... other codes
The handlePage
function takes a parameter which is the page number and the event. It prevents the default behaviour of the button in which it will be invoked, sets current page to the page number clicked and then fetches posts.
Render Pagination
1// ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5{
6 posts.length > 0 ? (
7 <Pagination activePage={currentPage} pageCount={pageCount}>
8 <PreviousLink
9 onClick={(e: FormEvent) => handlePageChange(e, currentPage - 1)}
10 >
11 Go to previous page
12 </PreviousLink>
13 {Array.from({ length: pageCount }, (_, index) => (
14 <PageLink
15 key={index}
16 number={index + 1}
17 onClick={(e: FormEvent) => handlePageChange(e, index + 1)}
18 >
19 Go to page {index + 1}
20 </PageLink>
21 ))}
22 <NextLink
23 onClick={(e: FormEvent) => handlePageChange(e, currentPage + 1)}
24 >
25 Go to next page
26 </NextLink>
27 </Pagination>
28 ) : null;
29}
30
31// ... other codes
Here is what we did in the code above:
<Pagination>
component takes two props:
activePage
and pageCount
<PreviousLink>
is used to navigate to the previous page. When clicked, it calls handlePageChange
with the current page number minus 1.<PageLink>
component is created. It's given a unique key based on the index. The number prop is set to the page number. When clicked, it calls handlePageChange
with the corresponding page number.<NextLink>
is used to navigate to the next page. When clicked, it calls handlePageChange
with the current page number plus 1.Let's see this in action:
Congratulations! We have added pagination. Next, let's add the Search functionality.
👉 See full code of the snippet for the PublishingTable
Component above.
Pagination has now been added to our plugin, but what happens when we want to find a particular post by blog title? For this reason, let's add a search functionality.
We want to implement a search feature to filter posts based on user input. So, we will create the following:
searchValue
: A variable that tracks the user's search input.handleSearchPost
: A function to make an API request to /content-publisher/search
with the search term (searchValue
) and fetch matching posts. The request is sent using the query parameter: search=${searchValue}
. If the search input is empty, the function will exit early to avoid unnecessary API calls. Here is what the search bar should look like:
Plugin Search Bar
getSearchQuery
Controller Method for SearchWe will update the getSearchQuery
Controller method to accept query parameters.
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 ctx.body = await strapi.plugin('content-publisher').service('service').getPosts(ctx.query);
12 },
13
14 // publish a blog post to dev.to
15 async publishPostToDevTo(ctx) {
16 ctx.body = await strapi
17 .plugin('content-publisher')
18 .service('service')
19 .publishPostToDevTo(ctx.request.body);
20 },
21
22 // publish a blog post to medium
23 async publishPostToMedium(ctx) {
24 ctx.body = await strapi
25 .plugin('content-publisher')
26 .service('service')
27 .publishPostToMedium(ctx.request.body);
28 },
29
30 // get a single post
31 async getSinglePost(ctx) {
32 ctx.body = await strapi
33 .plugin('content-publisher')
34 .service('service')
35 .getSinglePost(ctx.request.query);
36 },
37
38 // search for a post
39 async getSearchQuery(ctx) {
40 ctx.body = await strapi
41 .plugin('content-publisher')
42 .service('service')
43 .getSearchQuery(ctx.request.query);
44 },
45
46 // delete a post
47 async deletePost(ctx) {},
48});
49
50export default controller;
getSearchQuery
Service Method for Search FeatureInside the ./src/plugins/content-publisher/server/src/services/service.ts
file, we will create the getSearchQuery
method to help us fetch the requested query.
1// Path: ./src/plugins/content-publisher/server/src/services/service.ts
2
3// ... other codes
4
5/**
6 * Search Query
7 */
8async getSearchQuery(query: { search: string; start: number }) {
9 try {
10 // get search and start from query
11 const { search, start } = query;
12
13 // find total posts
14 const totalPosts = await strapi
15 .documents("plugin::medium-publisher.post")
16 .findMany({
17 filters: {
18 blog: { title: { $contains: search } },
19 },
20 });
21
22 // find posts
23 const posts = await strapi
24 .documents("plugin::medium-publisher.post")
25 .findMany({
26 populate: {
27 blog: {
28 populate: ["tags"],
29 },
30 },
31 // filter only blog titles that contains the search query
32 filters: {
33 blog: { title: { $contains: search } },
34 },
35 // return only 5 posts from the start index
36 start: start * postPerPage,
37 limit: postPerPage,
38 });
39
40 // return the posts and total posts
41 return { posts, totalPosts: totalPosts.length };
42 } catch (error) {
43 throw error;
44 }
45},
46
47// ... other codes
👉 See complete code of the snippet above
Here is what the method above does:
search
(the search term) and start
(the starting index for pagination) properties.$contains
filter to find posts where the blog title contains the search term.start
and limit
) and populates the blog relation along with its tags.PublishingTable
Component for the Search FeatureLet's update the PublishingTable
component file ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
so as to implement and display the search feature.
Import Search Bar Components We will import components from the Strapi design system that exists for the search user interface.
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5import {
6 // ... other codes
7
8 SearchForm,
9 Searchbar,
10}
11
12// ... other codes
In the code above, we imported the SearchForm
component which is the form for the search. Also, we imported the Searchbar
component which will serves as the input of our search value or key.
Create handleSearchPost
Method
Create a method called handleSearchPost
, which will handle search requests for posts.
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5const handleSearchPost = async (event: FormEvent, page: number | null) => {
6 event.preventDefault(); // Prevent the default form submission behavior
7 let start;
8 if (page === null) {
9 start = 0;
10 } else {
11 start = (page - 1) * postsPerPage; // Calculate the start index for the paginated search
12 }
13
14 // If the search input is empty, return early
15 if (!searchValue.trim()) {
16 return;
17 }
18
19 try {
20 // Make a GET request to the search endpoint
21 const response = await axios.get(
22 `/content-publisher/search?start=${start}&search=${searchValue}`,
23 );
24
25 setPosts(response.data.posts); // Assuming the API returns matching posts
26 setPageCount(Math.ceil(response.data.totalPosts / postsPerPage));
27 } catch (error) {
28 console.error("Error searching posts:", error);
29 }
30};
31
32// ... other codes
In the code above, the handleSearchPost
sends a search query to /content-publisher/search
with pagination (start and search query parameters). It updates posts
and pageCount
based on the API response.
Modify handlePageChange
Method to Include Search
Let's modify the handlePageChange
method to include the handleSearchPost
function.
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5const handlePageChange = (e: FormEvent, page: number) => {
6 if (page < 1 || page > pageCount) return;
7 setCurrentPage(page);
8
9 if (searchValue) {
10 handleSearchPost(e, page);
11 } else {
12 handleFetchPosts(page);
13 }
14};
15
16// ... other codes
The handlePageChange
now updates currentPage
and fetches data for the selected page. It determines whether to fetch default posts (handleFetchPosts
) or search results (handleSearchPost
) based on searchValue
.
Render Search Bar
Let's render the search bar in the PublishingTable
component.
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5<SearchForm
6 onSubmit={(e: FormEvent) => {
7 handleSearchPost(e, null);
8 }}
9>
10 <Searchbar
11 size="M"
12 name="searchbar"
13 onClear={() => {
14 setSearchValue("");
15 handleFetchPosts(1);
16 }}
17 value={searchValue}
18 onChange={(e: any) => setSearchValue(e.target.value)}
19 clearLabel="Clearing the plugin search"
20 placeholder="e.g: blog title"
21 >
22 Searching for a plugin
23 </Searchbar>
24</SearchForm>;
25
26// ... other codes
The code above renders a search form with a search bar to filter posts based on user input. When the form is submitted, it triggers the handleSearchPost
function to fetch filtered results. The onClear
handler resets the search value and fetches the default posts for page 1, while onChange
updates the searchValue
state as the user types.
Here is how the search works:
👉 See full code snippet for the PublishingTable
Component
What happens when we don't need a particular post entry?
For this reason, we want to be able to delete a post. So, let's implement a delete feature. All we need to do is send a DELETE
request with the post ID as a query parameter to the /delete-post
endpoint.
deletePost
Controller MethodIn the controller file ./src/plugins/content-publisher/server/src/controllers/controller.ts
, update the deletePost
controller method to accept query parameters:
1// Path: ./src/plugins/content-publisher/server/src/controllers/controller.ts
2
3
4import type { Core } from '@strapi/strapi';
5
6const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
7 // get blog entries
8 async getBlogs(ctx) {},
9
10 // get posts
11 async getPosts(ctx) {
12 ctx.body = await strapi.plugin('content-publisher').service('service').getPosts(ctx.query);
13 },
14
15 // publish a blog post to dev.to
16 async publishPostToDevTo(ctx) {
17 ctx.body = await strapi
18 .plugin('content-publisher')
19 .service('service')
20 .publishPostToDevTo(ctx.request.body);
21 },
22
23 // publish a blog post to medium
24 async publishPostToMedium(ctx) {
25 ctx.body = await strapi
26 .plugin('content-publisher')
27 .service('service')
28 .publishPostToMedium(ctx.request.body);
29 },
30
31 // get a single post
32 async getSinglePost(ctx) {
33 ctx.body = await strapi
34 .plugin('content-publisher')
35 .service('service')
36 .getSinglePost(ctx.request.query);
37 },
38
39 // search for a post
40 async getSearchQuery(ctx) {
41 ctx.body = await strapi
42 .plugin('content-publisher')
43 .service('service')
44 .getSearchQuery(ctx.request.query);
45 },
46
47 // delete a post
48 async deletePost(ctx) {
49 ctx.body = await strapi
50 .plugin('content-publisher')
51 .service('service')
52 .deletePost(ctx.request.query);
53 },
54});
55
56export default controller;
Inside the ./src/plugins/content-publisher/server/src/services/service.ts
, create a service to delete a post.
1// Path: ./src/plugins/content-publisher/server/src/services/service.ts
2
3// ... other codes
4
5/**
6 * DELETE Post
7 */
8async deletePost(query: { postId: string }) {
9 try {
10 // get postId from query
11 const { postId } = query;
12
13 // delete the post
14 await strapi
15 .documents("content::medium-publisher.post")
16 .delete({ documentId: postId });
17
18 return "Post deleted";
19 } catch (error) {
20 console.error("Error deleting post:", error);
21 throw error;
22 }
23},
24
25// ... other codes
👉 See complete code of the snippet above
In the deletePost
function above, we did the following:
postId
property of type string.postId
from the query object using destructuring.PublishingTable
for Deletion of PostsInside the PublishingTable
component, we will have to create a method to handle the deletion of posts. Create the method below:
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3
4// ... other codes
5
6
7const handleDeletePost = async (id: string) => {
8 const response: Response = await axios.delete(
9 `/content-publisher/delete-post?postId=${id}`,
10 {},
11 );
12 handleFetchPosts(1)
13};
14
15// ... other codes
Now, add the onClick
event listener to the Trash
icon to call the handleDeletePost
function when it is clicked.
1// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx
2
3// ... other codes
4
5<Trash
6 onClick={() => {
7 handleDeletePost(post.documentId);
8 }}
9 style={{ cursor: "pointer", color: "red" }}
10 width={20}
11 height={20}
12/>;
13
14
15// ... other codes
Here is what deleting a post looks like:
👉 See code to full code of the snippet above for the PublishingTable
component
Congratulations on building your first Strapi CMS plugin. Here are some suggestions on how to improve the Content Publisher plugin.
We can't wait to see you build amazing plugins.
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. The main
branch will hold future changes to this project.
In this final part of the Strapi plugin tutorial, we have learnt how to publish posts to Medium, add pagination, implement search functionality, and use injection zones in the Strapi admin panel and more.
What are you waiting for? Let's start building plugins!
Aside from building amazing plugins, Strapi headless CMS offers a range of features tailored to meet your business needs. Try Strapi today.
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.