Content is in high demand today, and a good blogging site is a great way of reaching out to people for learning, education, or entertainment purposes.
In this article, we shall describe how to go about integrating Strapi CMS with Next.js that is optimized with Tailwind CSS to develop a blog that targets developers.
At the end of this article, here is what you will learn:
In summary, by the end of this tutorial, you should know how to create on of the best developer blogs easy to use blog.
Here is what we will build:
Time to begin!
Strapi is an open source headless content management system (CMS) that allows users to build and manage content easily without having to worry about how to deliver it through a frontend application. This is quite advantageous to developers who want to build dynamic applications as it has an API that can be consumed by applications very fast.
Next.js is an all purpose react framework providing features such as server-side rendering, static site generation, automatic code splitting among many other web application enhancement features. These features are essential in ensuring that the application is performing at its best and the user is getting a good experience.
Here's the final result of the blog website you will build if you want to see it.
Check out the project repo here.
v5.0.x
v18.x.x
or v20.x.x.
You can download Node.js here.Open up your terminal and create a blog-strapi
folder to store your project files.
mkdir blog-strapi
Navigate into the blog-strapi
directory.
cd blog-strapi
Create your Strapi app in a folder named backend
.
npx create-strapi-app@latest backend --quickstart
The --quickstart
flag sets up your Strapi app with an SQLite database and automatically starts your server on port 1337
.
If the server is not already running in your terminal, cd
into the backend
folder and run npm develop
to launch it.
Visit http://localhost:1337/admin
in your browser and register in the Strapi Admin Registration Form.
Now that your Strapi application is set up and running fill out the form with your personal information to authenticate it to the Strapi Admin Panel.
From your admin panel, click on Content-Type Builder > Create new collection type tab to create a collection and their field types for your application. In this case, we are creating three collections: Author, Blog, and Category.
The Blog collection will contain the blog posts. It will have the following fields:
title
: Text (Long Text).description
: Text (Short Text).content
: Rich Text (Markdown).cover
: Media (Image upload).slug
: UID (Unique identifier based on the title)category
: Relation - many to many (Connect to a Category collection).The Author collection will contain the authors of the blog posts. It will have the following fields:
name
: Textavatar
: Media (Image upload)email
: Short Textblogs
: Relation with the Blogs collection - one to manyThe Category collection will contain the categories of the blog posts. It will have the following fields:
name
: Text (Short Text)slug
: UIDdescription
: Text (Short Text)blogs
: Relation - many to manyIn Strapi, relationships define how different content types interact with each other.
One-to-Many Relationship: This relationship exists when one record in one collection can be associated with multiple records in another collection. For example, one author can have multiple blog posts.
Many-to-Many Relationship: This relationship allows multiple records in one collection to be associated with multiple records in another collection. For instance, a blog post can belong to multiple categories and vice versa.
For more detailed information on relationships in Strapi, check out this guide.
Now that you’ve set up your collections, it’s time for some fun! You can input some dummy data for testing purposes. This will help you verify that everything is working smoothly before moving on.
Now let's allow API access to your blog to allow you access the blog posts via your API on the frontend. To do that, click on Settings > Roles > Public.
Afterward, collapse the Blog Permission tab and mark the Select all option and click on the Save button.
Scroll downwards and do the same for the Upload Permission tab to enable us upload images to our Strapi backend.
Strapi supports frontend frameworks including React, Next.js, Gatsby, Svelte, Vue Js etc. but for this application we'd be making use of Next Js.
In a new terminal session, change the directory to blog-strapi
and run the following command:
npx create-next-app@latest
On installation, you'll see some prompts. Name your project frontend
and refer to the image below for the other responses.
Note: Make sure you select the recommended
App Router
for Next JS because that's what we'll be using throughout our applicationInstall necessary dependencies
Add the following dependencies to your frontend Next app; we will use them later:
cd frontend
npm install react-hot-toast @tailwindcss/typography moment react-icons react-markdown react-loader-spinner remark-gfm rehype-raw react-syntax-highlighter
These libraries would be used throughout the application for several purposes.
After installation, add this plugin to your tailwind.config.ts
file to enable smooth markdown render in your application.
1// tailwind.config.ts
2module.exports = {
3 theme: {
4 // ...
5 },
6 plugins: [
7 require('@tailwindcss/typography'),
8 // ...
9 ],
10}
This is necessary when rendering markdown syntax with Tailwind on our page.
Create a .env
file in the root of your frontend
directory and add the following environment variables:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337/
NEXT_PUBLIC_PAGE_LIMIT=6
The NEXT_PUBLIC_STRAPI_URL
variable is used to connect to your Strapi backend.
The NEXT_PUBLIC_PAGE_LIMIT
variable is used to limit the number of blog posts displayed on each page for the pagination functionality.
Create a new file called lib/types.ts
in the frontend
directory and paste the following:
1// lib/types.ts
2// export Interface for Image Data
3export interface ImageData {
4 url: string;
5}
6
7// export Interface for Author Data
8export interface Author {
9 id: number; // Assuming each author has a unique ID
10 name: string;
11 email: string;
12 avatar: ImageData; // Assuming the author has
13}
14
15// export Interface for Category Data
16export interface Category {
17 documentId: string; // Assuming each category has a unique ID
18 name: string;
19 description: string; // Optional description
20}
21
22export interface BlogPost {
23 id: number;
24 title: string;
25 slug: string;
26 description: string;
27 content: string; // rich markdown text
28 createdAt: string; // ISO date string
29 cover: ImageData; // Assuming this is the structure of your featured image
30 author: Author; // The author of the blog post
31 categories: Category[]; // An array of categories associated with the post
32}
33
34export interface UserBlogPostData {
35 title: string;
36 slug: string;
37 description: string;
38 content: string; // rich markdown text
39}
40
41// Example response structure when fetching posts
42export interface BlogPostResponse {
43 data: BlogPost[];
44}
45
46// Example response structure when fetching a single post
47export interface SingleBlogPostResponse {
48 data: BlogPost; // The single blog post object
49}
This shows the structure of the various data types we would receive for our blog application.
Create another file within the lib
folder called api.ts
and paste the following functions
1// lib/api.ts
2import axios, { AxiosInstance } from "axios";
3import { UserBlogPostData } from "./types";
4
5export const api: AxiosInstance = axios.create({
6 baseURL: `${process.env.NEXT_PUBLIC_STRAPI_URL}`,
7});
8
9export const getAllPosts = async (
10 page: number = 1,
11 searchQuery: string = ""
12) => {
13 try {
14 // If search query exists, filter posts based on title
15 const searchFilter = searchQuery
16 ? `&filters[title][$containsi]=${searchQuery}`
17 : ""; // Search filter with the title
18 // Fetch posts with pagination and populate the required fields
19 const response = await api.get(
20 `api/blogs?populate=*&pagination[page]=${page}&pagination[pageSize]=${process.env.NEXT_PUBLIC_PAGE_LIMIT}${searchFilter}`
21 );
22 return {
23 posts: response.data.data,
24 pagination: response.data.meta.pagination, // Return data and include pagination data
25 };
26 } catch (error) {
27 console.error("Error fetching blogs:", error);
28 throw new Error("Server error"); // Error handling
29 }
30};
31
32// Get post by slug
33export const getPostBySlug = async (slug: string) => {
34 try {
35 const response = await api.get(
36 `api/blogs?filters[slug]=${slug}&populate=*`
37 ); // Fetch a single blog post using the slug parameter
38 if (response.data.data.length > 0) {
39 // If post exists
40 return response.data.data[0]; // Return the post data
41 }
42 throw new Error("Post not found.");
43 } catch (error) {
44 console.error("Error fetching post:", error);
45 throw new Error("Server error");
46 }
47};
48
49// Get all posts categories
50export const getAllCategories = async () => {
51 try {
52 const response = await api.get("api/categories"); // Route to fetch Categories data
53 return response.data.data; // Return all categories
54 } catch (error) {
55 console.error("Error fetching post:", error);
56 throw new Error("Server error");
57 }
58};
59
60// Upload image with correct structure for referencing in the blog
61export const uploadImage = async (image: File, refId: number) => {
62 try {
63 const formData = new FormData();
64 formData.append("files", image);
65 formData.append("ref", "api::blog.blog"); // ref: Strapi content-type name (in this case 'blog')
66 formData.append("refId", refId.toString()); // refId: Blog post ID
67 formData.append("field", "cover"); // field: Image field name in the blog
68
69 const response = await api.post("api/upload", formData); // Strapi route to upload files and images
70 const uploadedImage = response.data[0];
71 return uploadedImage; // Return full image metadata
72 } catch (err) {
73 console.error("Error uploading image:", err);
74 throw err;
75 }
76};
77
78// Create a blog post and handle all fields
79export const createPost = async (postData: UserBlogPostData) => {
80 try {
81 const reqData = { data: { ...postData } }; // Strapi required format to post data
82 const response = await api.post("api/blogs", reqData);
83 return response.data.data;
84 } catch (error) {
85 console.error("Error creating post:", error);
86 throw new Error("Failed to create post");
87 }
88};
Let's talk about each of the following functions above:
api
: This is an instance of Axios, configured with a base URL pointing to your Strapi backend. It simplifies making HTTP requests to your API.
getAllPosts(page: number = 1, searchQuery: string = "")
: This fetches all blog posts from the Strapi API, allowing for pagination and optional filtering based on a search query. It returns the posts and pagination data.
getPostBySlug(slug: string)
: This retrieves a single blog post based on its slug. It checks if the post exists and returns the corresponding data or throws an error if it is not found.
getAllCategories()
: This fetches all categories from the Strapi API, returning their data for use in categorizing blog posts.
uploadImage(image: File, refId: number)
: This handles image uploads to the Strapi API. It prepares a FormData
object with the image and its associated metadata (like reference ID and field name) and sends it to the server.
createPost(postData: UserBlogPostData)
: This creates a new blog post by sending the provided post data to the Strapi API. It returns the created post's data or throws an error if the creation fails.
Navbar
Component and Implement Search HandlingCreate a components
folder within the src
folder and create a Navbar.tsx
file within the folder. The Navbar
component would handle search functionality as well as links to other routes.
1// src/components/Navbar.tsx
2"use client";
3import React, { useState } from "react";
4import Link from "next/link";
5import { FaPen, FaSearch, FaTimes } from "react-icons/fa";
6import { useSearchParams, usePathname, useRouter } from "next/navigation";
7
8const Navbar = () => {
9 const [searchOpen, setSearchOpen] = useState(false);
10 const [searchQuery, setSearchQuery] = useState("");
11 const searchParams = useSearchParams();
12 const pathname = usePathname(); // Get the current route path
13 const { replace } = useRouter(); // Next js function to replace routes
14
15 // Handle search query submission
16 const handleSearchSubmit = () => {
17 const params = new URLSearchParams(searchParams);
18 if (searchQuery) params.set("search", searchQuery);
19 else params.delete("search");
20 // Always routes with the search query
21 replace(`/?${params.toString()}`);
22 setSearchOpen(false); // Close search bar after submission
23 };
24
25 return (
26 <div className="max-w-screen-lg mx-auto sticky top-0 bg-inherit py-3 sm:py-6 z-50 ">
27 <nav className="flex justify-between items-center mb-2 p-4 ">
28 <div className="flex items-center gap-4">
29 <Link href="/">
30 <h1 className="font-bold text-xl text-purple-600 font-jet-brains">
31 DEV.BLOG
32 </h1>
33 </Link>
34 <button
35 onClick={() => setSearchOpen((prev) => !prev)}
36 className="text-xl text-white hover:text-purple-400 transition-colors"
37 >
38 {searchOpen ? <FaTimes /> : <FaSearch />}
39 </button>
40
41 {/* Search Box (toggles visibility) */}
42 {searchOpen && (
43 <div className="ml-4 flex items-center gap-2">
44 <input
45 type="search"
46 value={searchQuery}
47 onChange={(e) => setSearchQuery(e.target.value)}
48 placeholder="Search posts..."
49 defaultValue={searchParams.get("search")?.toString()}
50 className="bg-gray-800 appearance-none placeholder:text-sm placeholder:font-normal text-sm text-white placeholder-gray-400 border-b-2 border-purple-500 focus:border-purple-300 outline-none px-2 py-1 rounded-md"
51 />
52 <button
53 onClick={handleSearchSubmit}
54 className="bg-purple-600 text-sm hover:bg-purple-500 text-white px-2 py-1 rounded-md transition-colors"
55 >
56 Search
57 </button>
58 </div>
59 )}
60 </div>
61
62 <ul className="flex items-center gap-6 font-medium transition-colors font-jet-brains">
63 <li
64 className={
65 pathname === "/"
66 ? "text-purple-400"
67 : "text-white hover:text-purple-400"
68 }
69 >
70 <Link href="/">Blogs</Link>
71 </li>
72 <li
73 className={
74 pathname === "/write"
75 ? "text-purple-400"
76 : "text-white hover:text-purple-400"
77 }
78 >
79 <Link href="/write">
80 <FaPen className="hover:text-purple-400" />
81 </Link>
82 </li>
83 </ul>
84 </nav>
85 </div>
86 );
87};
88
89export default Navbar;
The Navbar
component imports necessary components and functions.
The useState
is used to manage the search bar visibility (searchOpen
) and search query (searchQuery
).
useSearchParams
helps retrieve the current search parameters, and usePathname
provides the current route. useRouter
is used to programmatically update the route when submitting a search query.
When the user types in the search input and clicks the "Search" button, the query is captured and appended to the URL as a search parameter. If the search query is empty, the search parameter is removed.
It also includes navigation links for Blogs
and Write
pages, with the active route highlighted by changing text color using pathname.
Create a Loader
and Pagination
component within the components
folder for the data handling and pagination
Loader
componentThe Loader
component would be used to indicate the loading state when fetching data from Strapi.
1// // src/components/Loader.tsx
2import { LineWave } from "react-loader-spinner";
3
4const Loader = () => {
5 return (
6 <LineWave
7 visible={true}
8 height="150"
9 width="150"
10 color="#7e22ce"
11 ariaLabel="line-wave-loading"
12 wrapperStyle={{}}
13 />
14 );
15};
16
17export default Loader;
Pagination
componentThe Pagination
component would be used to implement pagination for the blogs.
1// src/components/Pagination.tsx
2import React from "react";
3import { FaArrowLeft, FaArrowRight } from "react-icons/fa"; // Import arrow icons from react-icons
4
5interface PaginationProps {
6 currentPage: number;
7 totalPages: number;
8 onPageChange: (newPage: number) => void;
9}
10
11const Pagination: React.FC<PaginationProps> = ({
12 currentPage,
13 totalPages,
14 onPageChange,
15}) => {
16 return (
17 <div className="mt-8 flex justify-center items-center space-x-4">
18 <button
19 disabled={currentPage === 1}
20 onClick={() => onPageChange(currentPage - 1)}
21 className={`px-4 py-2 bg-purple-700 text-white rounded ${
22 currentPage === 1
23 ? "opacity-50 cursor-not-allowed"
24 : "hover:bg-purple-600"
25 }`}
26 >
27 <FaArrowLeft /> {/* Previous button icon */}
28 </button>
29 <span className="text-white">
30 Page {currentPage} of {totalPages}
31 </span>
32 <button
33 disabled={currentPage === totalPages || currentPage > totalPages}
34 onClick={() => onPageChange(currentPage + 1)}
35 className={`px-4 py-2 bg-purple-700 text-white rounded ${
36 currentPage === totalPages || currentPage > totalPages
37 ? "opacity-50 cursor-not-allowed"
38 : "hover:bg-purple-600"
39 }`}
40 >
41 <FaArrowRight /> {/* Next button icon */}
42 </button>
43 </div>
44 );
45};
46
47export default Pagination;
In the app/layout.tsx
import the Navbar
component from your components
folder and Toaster
component from the react-hot-toast
package and add them to the layout.
Update the metadata
object by adjusting the title, description, and icon to suit your application.
1// app/layout.tsx
2import type { Metadata } from "next";
3import Navbar from "@/components/Navbar";
4import localFont from "next/font/local";
5import { Toaster } from "react-hot-toast";
6import "./globals.css";
7
8const geistSans = localFont({
9 src: "./fonts/GeistVF.woff",
10 variable: "--font-geist-sans",
11 weight: "100 900",
12});
13const geistMono = localFont({
14 src: "./fonts/GeistMonoVF.woff",
15 variable: "--font-geist-mono",
16 weight: "100 900",
17});
18
19export const metadata: Metadata = {
20 title: "DEV.BLOG",
21 description:
22 "Your go-to resource for all things Strapi—explore best practices, tips, and community insights to elevate your projects",
23};
24
25export default function RootLayout({
26 children,
27}: Readonly<{
28 children: React.ReactNode;
29}>) {
30 return (
31 <html lang="en" data-color-mode="dark">
32 <body
33 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
34 >
35 <Navbar />
36 {children}
37 <Toaster />
38 </body>
39 </html>
40 );
41}
You will create the home page for your blog frontend in this step. In this case, the home page will display a list of all the blog posts.
There should be a file named page.tsx in the src/app/
directory.
Add the following code to page.tsx
:
1// src/app/page.tsx
2/* eslint-disable @next/next/no-img-element */
3"use client";
4import { useEffect, useState } from "react";
5import { useSearchParams, useRouter } from "next/navigation";
6import Link from "next/link";
7import { getAllPosts } from "../lib/api";
8import { BlogPost } from "@/lib/types";
9import Loader from "@/components/Loader";
10import Pagination from "@/components/Pagination";
11
12export default function Home() {
13 const [posts, setPosts] = useState<BlogPost[]>([]);
14 const [loading, setLoading] = useState(true);
15 const [error, setError] = useState<string | null>(null);
16 const [totalPages, setTotalPages] = useState(1); // Track total number of pages
17
18 const searchParams = useSearchParams();
19 const router = useRouter();
20
21 // Get the search query and page from the URL params
22 const searchQuery = searchParams.get("search") ?? "";
23 const pageParam = searchParams.get("page");
24 const currentPage = pageParam ? parseInt(pageParam) : 1; // Default to page 1 if not present
25
26 useEffect(() => {
27 const fetchPosts = async (page: number) => {
28 try {
29 const { posts, pagination } = await getAllPosts(page, searchQuery);
30 setPosts(posts);
31 setTotalPages(pagination.pageCount); // Set total pages
32 } catch (error) {
33 setError("Error fetching posts.");
34 console.error("Error fetching posts:", error);
35 } finally {
36 setLoading(false);
37 }
38 };
39
40 fetchPosts(currentPage);
41 }, [currentPage, searchQuery]); // Re-fetch when page or search query changes
42
43 const handlePageChange = (newPage: number) => {
44 // Update the page parameter in the URL
45 const newParams = new URLSearchParams(searchParams.toString());
46 newParams.set("page", newPage.toString());
47 router.push(`?${newParams.toString()}`);
48 setLoading(true); // Show loader while fetching
49 };
50
51 return (
52 <div className="max-w-screen-lg mx-auto p-4">
53 {loading && (
54 <div className="w-full flex items-center justify-center">
55 <Loader />
56 </div>
57 )}
58 {error && <p>{error}</p>}
59
60 {!loading && !error && (
61 <>
62 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
63 {posts.length > 0 ? (
64 posts.map((post) => (
65 <div
66 key={post.id}
67 className="cursor-pointer bg-gray-900 rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
68 >
69 <Link href={`/blogs/${post.slug}`} className="block">
70 {post.cover?.url && (
71 <div className="relative h-36 w-full">
72 <img
73 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${post.cover.url}`}
74 alt={post.title}
75 className="w-full h-full object-cover"
76 />
77 </div>
78 )}
79 <div className="p-4">
80 <h2 className="text-lg font-semibold font-jet-brains text-white line-clamp-2">
81 {post.title}
82 </h2>
83 <p className="text-gray-400 mt-2 text-sm leading-6 line-clamp-3">
84 {post.description}
85 </p>
86 <p className="text-purple-400 text-sm mt-4 inline-block font-medium hover:underline">
87 Read More
88 </p>
89 </div>
90 </Link>
91 </div>
92 ))
93 ) : (
94 <p className="text-gray-400">No posts available at the moment.</p>
95 )}
96 </div>
97
98 {/* Pagination Controls */}
99 <Pagination
100 currentPage={currentPage}
101 totalPages={totalPages}
102 onPageChange={handlePageChange} // Update page when pagination changes
103 />
104 </>
105 )}
106 </div>
107 );
108}
This code sets up a Next.js page that will fetch a list of all the blogs from the Strapi API /blogs
endpoint and renders them in a neat blog-like format.
We use the useEffect
hook to fetch a list of all the blogs from the endpoint and renders them in a neat blog-like format. The fetchPosts
function uses the getAllPosts
function in our /lib/api.tsx
file to make the API request.
While the data is being fetched, a Loader
component is displayed. If an error occurs or the post is not found, appropriate messages are shown.
The handlePageChange
function is used to manage pagination in our blog. When a user clicks to navigate to a new page, this function updates the URL with the new page number while preserving any existing search parameters.
It creates a new URLSearchParams
object, sets the page parameter to the selected page number, and then uses router.push
to navigate to the updated URL. Finally, it sets a loading state to true, which can be used to show a loader while the new content is being fetched.
In each post we are making use of Next Js Link
component to route users to a single post with each post's unique slug
. In our next step, we would use the slug to make a query to fetch the data in our single blog post page.
To create a single blog page, the next step is to set up the necessary folder structure. In the app
directory, create a new folder named blogs
, and then create a subfolder called [slug]
within it. Finally, add a file named pages.tsx
inside the [slug]
folder. This structure will look like this: app/blogs/[slug]/pages.tsx.
The reason for creating a folder named [slug]
is that it allows us to define dynamic routes in Next.js.
The [slug]
part of the folder name acts as a placeholder for the unique identifier of each blog post, enabling the application to render different content based on the specific slug in the URL. This way, when users navigate to a blog post, they will see the corresponding content based on its unique slug. For example, /blogs/slug-of-blog-post
Paste the following code in your page.tsx
file.
1// app/blogs/[slug]/page.tsx.
2"use client";
3import { useEffect, useState } from "react";
4import { getPostBySlug } from "../../../lib/api"; // Import your API function
5import { useRouter } from "next/navigation";
6import { BlogPost } from "@/lib/types";
7import Markdown from "react-markdown";
8import remarkGfm from "remark-gfm";
9import rehypeRaw from "rehype-raw";
10import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
11import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
12import { FaClipboard } from "react-icons/fa"; // Import your chosen icon
13import Loader from "@/components/Loader";
14import moment from "moment";
15import { toast } from "react-hot-toast";
16
17
18const handleCopyCode = async (code: string) => {
19 try {
20 await navigator.clipboard.writeText(code);
21 toast.success("Code copied to clipboard!"); // Show toast on error
22 } catch (err) {
23 console.error("Failed to copy code: ", err);
24 }
25};
26
27const BlogPostPage = ({ params }: { params: { slug: string } }) => {
28 const { slug } = params;
29 const [post, setPost] = useState<BlogPost | null>(null);
30 const [loading, setLoading] = useState<boolean>(true);
31 const [error, setError] = useState<string | null>(null);
32 const router = useRouter();
33
34 useEffect(() => {
35 const fetchPost = async () => {
36 if (slug) {
37 try {
38 // Fetch the post using the slug
39 const fetchedPost = await getPostBySlug(slug);
40 setPost(fetchedPost);
41 } catch (err) {
42 setError("Error fetching post.");
43 console.log(err);
44 } finally {
45 setLoading(false);
46 }
47 }
48 };
49
50 fetchPost();
51 }, [slug]);
52
53 if (loading)
54 return (
55 <div className="max-w-screen-md mx-auto flex items-center justify-center">
56 <Loader />
57 </div>
58 );
59 if (error) return <p className="max-w-screen-md mx-auto">Error: {error}</p>;
60 if (!post) return <p className="max-w-screen-md mx-auto">No post found.</p>;
61 console.log(post);
62 return (
63 <div className="max-w-screen-md mx-auto p-4">
64 <h1 className="text-4xl leading-[60px] capitalize text-center font-bold text-purple-800 font-jet-brains">
65 {post.title}
66 </h1>
67 <div className="w-full flex items-center justify-center font-light">
68 Published: {moment(post.createdAt).fromNow()}
69 </div>
70
71 {/* Categories Section */}
72 {post.categories && post.categories.length > 0 && (
73 <div className="flex flex-wrap space-x-2 my-4">
74 {post.categories.map(({ name, documentId }) => (
75 <span
76 key={documentId}
77 className="border border-purple-900 font-medium px-2 py-2 text-sm"
78 >
79 {name}
80 </span>
81 ))}
82 </div>
83 )}
84
85 {post.cover && (
86 <div className="relative h-72 w-full my-4">
87 <img
88 src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${post.cover.url}`}
89 alt={post.title}
90 className="rounded-lg w-full h-full object-cover"
91 />
92 </div>
93 )}
94 <p className="text-gray-300 leading-[32px] tracking-wide italic mt-2 mb-6">
95 {post.description}
96 </p>
97 <Markdown
98 className={"leading-[40px] max-w-screen-lg prose prose-invert"}
99 remarkPlugins={[remarkGfm]}
100 rehypePlugins={[rehypeRaw]}
101 components={{
102 code({ inline, className, children, ...props }: any) {
103 const match = /language-(\w+)/.exec(className || "");
104 const codeString = String(children).replace(/\n$/, "");
105
106 return !inline && match ? (
107 <div className="relative">
108 <button
109 onClick={() => handleCopyCode(codeString)}
110 className="absolute top-2 right-2 bg-gray-700 text-white p-1 rounded-md hover:bg-gray-600"
111 title="Copy to clipboard"
112 >
113 <FaClipboard color="#fff" />
114 </button>
115 <SyntaxHighlighter
116 style={dracula}
117 PreTag="div"
118 language={match[1]}
119 {...props}
120 >
121 {codeString}
122 </SyntaxHighlighter>
123 </div>
124 ) : (
125 <code className={className} {...props}>
126 {children}
127 </code>
128 );
129 },
130 }}
131 >
132 {post.content}
133 </Markdown>
134 <button
135 onClick={() => router.back()}
136 className="text-purple-800 mt-4 inline-block hover:underline"
137 >
138 Back to Blogs
139 </button>
140 </div>
141 );
142};
143
144export default BlogPostPage;
In the page.tsx
file, we created a dynamic blog post page using the [slug]
dynamic route. This component allows us to fetch and display blog post content based on the slug from the URL.
Here’s a breakdown of the main features:
We use the useEffect
hook to fetch the blog post data from an API using the getPostBySlug
function, which takes the slug
as a parameter. The fetched data is then stored in the component’s state.
The blog content is written in markdown, and we use the react-markdown
package to render it, supporting various markdown syntax features like headings, lists, and links. The remark-gfm
plugin adds GitHub-flavored markdown, while rehype-raw
allows rendering raw HTML.
Code blocks within the blog are styled using react-syntax-highlighter
with the Dracula
theme. We also provide a button for users to copy code snippets to their clipboard.
The component displays the blog’s title
, description
, publication date
(formatted with Moment.js
), categories
, and cover image
(if available).
A "Back to Blogs" button allows users to navigate back to the blog listing.
This setup allows us to dynamically render blog posts, enhancing the user experience with features like code copying and syntax highlighting.
We will proceed to create a WritePost
component to enable users to write posts and upload images seamlessly.
Create a folder in the app
directory called write
and create a page.tsx
file to create a new page with the route /write
.
Paste the following code in the page.tsx
1// app/write/page.tsx.
2"use client";
3import React, { useState } from "react";
4import { useRouter } from "next/navigation";
5import { FaArrowLeft } from "react-icons/fa";
6import slugify from "react-slugify";
7import MarkdownEditor from "@uiw/react-markdown-editor";
8import Image from "next/image";
9import { createPost, uploadImage } from "@/lib/api";
10import { toast } from "react-hot-toast";
11
12const WritePost = () => {
13 const [markdownContent, setMarkdownContent] = useState("");
14 const [title, setTitle] = useState("");
15 const [description, setDescription] = useState("");
16 const [coverImage, setCoverImage] = useState<File | null>(null);
17 const [imagePreview, setImagePreview] = useState<string | null>(null);
18 const [isLoading, setIsLoading] = useState(false); // Loading state
19 const [error, setError] = useState<string | null>(null); // Error state
20 const router = useRouter();
21
22 // Handle image upload and preview
23 const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
24 if (e.target.files && e.target.files[0]) {
25 const selectedImage = e.target.files[0];
26 setCoverImage(selectedImage);
27 setImagePreview(URL.createObjectURL(selectedImage));
28 }
29 };
30
31 // Handle post submission
32 const handleSubmit = async () => {
33 setIsLoading(true);
34 setError(null);
35 try {
36 // Create slug from the title
37 const postSlug = slugify(title);
38 // Create the post initially without the image
39 const postData = {
40 title,
41 description,
42 slug: postSlug,
43 content: markdownContent,
44 };
45
46 // Step 1: Create the blog post without the cover image
47 const postResponse = await createPost(postData);
48 const postId = postResponse.id;
49 console.log(postId);
50
51 // Step 2: Upload cover image (if provided) and associate with blog post
52 if (coverImage) {
53 const uploadedImage = await uploadImage(coverImage, postId);
54 console.log("Image uploaded:", uploadedImage);
55 }
56
57 // Redirect after successful post creation
58 router.push(`/blogs/${postSlug}`);
59 toast.success("Post created successfully");
60 } catch (error) {
61 console.error("Failed to create post:", error);
62 setError("Failed to create post. Please try again.");
63 toast.error("Failed to create post. Please try again.");
64 } finally {
65 setIsLoading(false);
66 }
67 };
68
69 return (
70 <div className="max-w-screen-md mx-auto p-4">
71 <button
72 onClick={() => router.back()}
73 className="text-purple-400 hover:text-purple-500 mb-6 flex items-center space-x-2"
74 >
75 <FaArrowLeft /> <span>Back</span>
76 </button>
77
78 <h1 className="text-xl font-bold mb-4 text-gray-100 font-jet-brains">
79 Create New Post
80 </h1>
81 {/* Render a message if there is an error */}
82 {error && (
83 <div className="mb-4 p-3 bg-red-600 text-white rounded-md">{error}</div>
84 )}
85
86 <div className="mb-4">
87 <input
88 type="text"
89 placeholder="Enter a Title"
90 value={title}
91 onChange={(e) => setTitle(e.target.value)}
92 className="w-full p-2 font-jet-brains text-3xl font-semibold bg-[#161b22] text-gray-100 border-b border-gray-600 focus:border-purple-500 focus:outline-none placeholder-gray-400"
93 />
94 </div>
95 <div className="mb-4">
96 <textarea
97 placeholder="Description"
98 value={description}
99 onChange={(e) => setDescription(e.target.value)}
100 className="w-full p-2 font-jet-brains bg-[#161b22] font-semibold text-gray-100 border-b border-gray-600 focus:border-purple-500 focus:outline-none placeholder-gray-400"
101 />
102 </div>
103 <div className="mb-6">
104 <input
105 type="file"
106 accept="image/*"
107 onChange={handleImageChange}
108 className="w-full bg-[#161b22] text-gray-100"
109 />
110 {imagePreview && (
111 <div className="mt-4">
112 <Image
113 src={imagePreview}
114 alt="Selected Cover"
115 width="100"
116 height="100"
117 className="w-full h-auto rounded-md"
118 />
119 </div>
120 )}
121 </div>
122
123 <div className="mb-6">
124 <MarkdownEditor
125 value={markdownContent}
126 height="200px"
127 onChange={(value) => setMarkdownContent(value)}
128 className="bg-[#161b22] text-gray-100"
129 />
130 </div>
131
132 <button
133 onClick={handleSubmit}
134 disabled={isLoading || (!title && !description)}
135 className="bg-purple-600 text-gray-100 py-2 px-4 rounded-md hover:bg-purple-500"
136 >
137 {isLoading ? "Loading" : "Post"}
138 </button>
139 </div>
140 );
141};
142
143export default WritePost;
The WritePost
component allows users to write and submit a blog post, including uploading a cover image. Let's break down its key functionalities below:
The component uses React’s useState
hook to manage the following states:
markdownContent
: Stores the content of the blog post written in Markdown format.title
and description
: Manage the post's title and description, which will be displayed on the blog.coverImage
: Holds the image file selected by the user for the post’s cover.imagePreview
: Provides a URL to preview the selected cover image before submission.isLoading
: Tracks whether the post submission is in progress. While loading, the submit button is disabled.error
: Captures and displays any errors encountered during form submission (e.g., failed API requests).The component includes the handleImageChange
function, which manages image uploads:
coverImage
state.URL.createObjectURL()
, allowing the user to see the image before submitting it.This feature provides visual feedback, letting users know what image they are uploading as the post’s cover.
The post content is written in Markdown format using the @uiw/react-markdown-editor
package. This editor simplifies text formatting for headings, lists, links, and more.
onChange
event handler updates the markdownContent
state as the user types, ensuring that all content is captured.The handleSubmit
function processes the form submission in two steps:
Create the Post:
slugify(title)
. The slug is a URL-friendly version of the title (e.g., "My First Post" becomes "my-first-post").createPost
function. The server responds with the post’s ID.Upload the Cover Image:
uploadImage
function.After successful creation and image upload:
/blogs/${postSlug}
).toast.success()
.toast.error()
.isLoading
state ensures that the submit button is disabled during submission to prevent duplicate posts.The WritePost
component provides a comprehensive experience for users to write blog posts with Markdown formatting, upload a cover image, and submit the post to the server. It offers visual feedback (image previews), handles loading states, and ensures that errors are displayed when necessary. The blog post is created and saved in two steps: content creation and image uploading, ensuring that each part is handled correctly.
Check the official documentation to read up on hosting your strapi application to Strapi Cloud and more in more detail: Strapi Hosting Documentation.
In this guide, we explored how to build one of the best developer blogs application using Next.js and Strapi 5. From setting up our development environment to creating content models, setting up the application layout, creating dynamic routes for individual blog posts, we covered how to effectively manage state, implement pagination and search functionality, handle image uploads with strapi, and implement a markdown editor for content creation.
Feel free to clone the application from the GitHub repository and extend its functionality. To learn more about Strapi and its other impressive features, you can visit the official documentation.
I am a Software Engineer and Technical writer, interested in Scalability, User Experience and Architecture.