How to Safely Render Markdown From a React Component
React-markdown is a library that provides the React component to render the Markdown markup. Built on remark, a markdown preprocessor.
With react-markdown, you can safely render markdown and not have to rely on the dangerouslySetInnerHTML
prop, instead React markdown uses a syntax tree to build a virtual DOM for your markdown.
Markdown is a lightweight markup language that is easy to learn and work with it, created to include basic tags, it helps to create editorial content easily. However, its important to note that React markdown does not support HTML by default and therefore prevents script injection, making it safer to use.
Script injection is a security vunerability that allows malicious code to the user interface element of an application, you can read more about it here.
Why use React markdown
There are many markdown component libraries available for use in React applications, some of which include react-markdown-editor-lite, commonmark-react-renderer and react-remarkable. These libraries although great all focus on dangerouslySetInnerHTML
, however react-markdown uses a syntax tree to build the virtual DOM that allows for updating only the changing DOM instead of completely overwriting it. React markdown also supports CommonMark and has extensions to support custom syntax and a list of plugins to extend it’s features.
React markdown vs remark
react-markdown
is a library that provides the React component to render the Markdown markup while remark is a markdown preprocessor built on micromark. It inspects, parses and transforms markdowns.
Getting Started with React markdown
In this section, we will build a markdown blog using react markdown, with this application, users can write an article in markdown which when completed can be previewed in plain text. First, let’s create a new project and install react-markdown
Initializing Project
To initialize a new react typescript project, run the command below
npx create-react-app react-markdown-blog --template typescript
In the code above, we initialized a new react typescript project with the application name “react-markdown-app”, this can be replaced with whatever name you choose.
Next, let’s install dependencies and start our application’s development server below
yarn add autoprefixer postcss-cli postcss tailwindcss moment react-markdown
In the code above, we installed the following dependencies, postcss-cl``i
, postcss
for transforming styles with JS plugins and help us lint our CSS and tailwindcss
for our styling, react-markdown
for rendering our markdown component, and moment
for parsing our dates, autoprefixer
for adding vendor prefixes to our CSS rules.
Next, we need to setup tailwindcss for our project, type the following command in your terminal to generate a tailwind config file in your project directory.
npx tailwind init tailwind.config.js
By default tailwindcss prevents markdown styles from displaying. To fix that, install a tailwind plugin @tailwindcss/typography
according to documentation,
The @tailwindcss/typography plugin adds a set of customizable
prose
classes that you can use to add beautiful typographic defaults to any vanilla HTML, like the output you’d get after parsing some Markdown, or content you pull from a CMS.
Inside the @tailwindcss.config.js
file, add the code below
module.exports = {
purge: [],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
typography: {
DEFAULT: {
css: {
color: "#FFF",
a: {
color: "#4798C5",
"&:hover": {
color: "#2c5282",
},
},
h1: {
color: "#FFF",
},
h2: {
color: "#FFF",
},
h3: {
color: "#FFF",
},
h4: { color: "#FFF" },
em: { color: "#FFF" },
strong: { color: "#FFF" },
blockquote: { color: "#FFF" },
code: { backgroundColor: "#1A1E22", color: "#FFF" },
},
},
},
},
},
variants: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};
Run the below command to generate a postcss config file in your project directory
touch postcss.config.js
Add the code below in the file;
const tailwindcss = require('tailwindcss');
module.exports = {
plugins: [
tailwindcss('./tailwind.config.js'),
require('autoprefixer')
],
};
Create a folder called styles
in src and add 2 files main.css
(where generated tailwind styles will enter) and tailwind.css
for tailwind imports.
Inside tailwind.css
, add the codes below
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
Update your scripts in package.json
to build tailwind styles when the dev server starts
"scripts": {
"start": "npm run watch:css && react-scripts start",
"build": "npm run watch:css && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"watch:css": "postcss src/styles/tailwind.css -o src/styles/main.css"
}
Lastly, add main.css
to src/index.ts
, the file should like this;
import React from "react";
import ReactDOM from "react-dom";
import "./styles/main.css";
import "./index.css";
import App from "./App";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
To start our development server, let’s start our development server using the command below
yarn start
OR using NPM
npm start
Building Navbar Component
Here, we will create components for our applications, first navigate to your src
folder and inside it, create a new folder named components
, this will house all of our project components. Inside your components
folder, create a new directory called Navbar
, inside this directory, create a new file called Navbar.tsx
.
Firstly, add react-router-dom
for navigation within the app and two react-markdown plugins to extends react-markdown features; we’ll be passing them to the Markdown component.
yarn add react-router-dom remark-gfm rehype-raw
Inside this file, let’s create our Navbar
with the code block below:
import { Link, NavLink } from "react-router-dom";
const Navbar = () => {
return (
<nav className="mt-6 w-10/12 mx-auto flex justify-between items-center">
<Link
style={{ fontStyle: "oblique" }}
className="text-xl tracking-widest logo"
to="/"
>
Markdown Blog
</Link>
<div className="flex-items-center">
<NavLink
activeClassName="border-b-2"
className="mr-6 tracking-wider"
to="/write-article"
exact
>
Write An Article
</NavLink>
<NavLink
activeClassName="border-b-2"
className="tracking-wider"
to="/profile"
exact
>
Profile
</NavLink>
</div>
</nav>
);
};
export default Navbar;
In the code blog above, we created a function component called Navbar
, inside it we created a Navbar
component, giving our application a title, we also added links to a Profile
page and to a page where user can write an article.
Next, we will create a blog card that will feature our blog posts, this will show each blog post written and we’d use react-markdown
to render the articles in plain text with styles. Let’s do that in the section below.
Adding Helper functions
Before we build our BlogCard, we need to add some helperFunctions that will help us:
- Delete, edit, save and fetch posts from localStorage (our DB).
- Format the date of the post.
- Truncate the post body so our BlogCard doesn’t get too big when the body is very lengthy.
Firstly, inside the src
folder create a folder called utils
, in there create a new file and name it helperFunctions.ts
add the code block below.
import moment from "moment";
export const truncateText = (text: string, maxNum: number) => {
const textLength = text.length;
return textLength > maxNum ? `${text.slice(0, maxNum)}...` : text;
};
export const formatDate = (date: Date) => {
return moment(date).format("Do MMM YYYY, h:mm:ss a");
};
Above, we have a function called textTruncate
that accepts in two arguments; text and maxNum.
it checks if the text has a length greater than the maxNumber we want to display, if yes we remove the surplus and add ‘…’ else we return the text.
The second function; formatDate
basically formats a date object passed to it to the format we want to display in our app. Let’s add more functions to manipulate our posts with below.
Create another file inside utils
and name it server.ts
it will contain the functions to add, delete, fetch and edit a post. Add the code below
import { IBlogPost } from "../components/BlogCard/BlogCard";
export const savePost = (post: Partial<IBlogPost>) => {
if (!localStorage.getItem("markdown-blog")) {
localStorage.setItem("markdown-blog", JSON.stringify([post]));
} else {
const posts = JSON.parse(localStorage.getItem("markdown-blog") as string);
localStorage.setItem("markdown-blog", JSON.stringify([post, ...posts]));
}
};
export const editPost = (newPostContent: IBlogPost) => {
const posts: IBlogPost[] = JSON.parse(
localStorage.getItem("markdown-blog") as string
);
const postIdx = posts.findIndex((post) => post.id === newPostContent.id);
posts.splice(postIdx, 1, newPostContent);
localStorage.setItem("markdown-blog", JSON.stringify(posts));
};
In the code we added above, savePost
functions takes in an object of type BlogPost which we will create in our BlogCard component. It checks if we have saved any post to our browser’s localStorage from this app with the key markdown-blog*,* if **none is found we add the post to an array and save it to localStorage. Otherwise we fetch the posts we already have and include it before saving.
We have also added a function called editPost
which we will use to edit posts, it takes in the newContent
object which will contain updated post properties, in here we fetch all posts, find the index of the post we want to edit and splice it out and replace it with the newContent
at that index in the array as seen in line 34. after splicing we save it back to locatStorage.
Let’s add the other two functions below.
export const getPosts = () => {
if (!localStorage.getItem("markdown-blog")) {
return [];
} else {
const posts = JSON.parse(localStorage.getItem("markdown-blog") as string);
return posts;
}
};
export const deletePost = (id: string) => {
const posts: IBlogPost[] = JSON.parse(
localStorage.getItem("markdown-blog") as string
);
const newPostList = posts.filter((post) => post.id !== id);
localStorage.setItem("markdown-blog", JSON.stringify(newPostList));
};
getPost
functions fetches our posts from localStorage and returns them if we have that post in localStorage or an empty array if we don’t.
deletePost
takes in an id as an argument, fetches the all posts from loalStorage, filters out the one with the id passed to this function and saves the rest to localStorage.
Building Blog Card component
As we have now added our helper functions, let’s create a blog card component for our application, to do this we’d first create a typescript interface (which we will pass to our savePost and editPost functions created above) to define the type
of each prop to be passed to the BlogCard
component, next we will create our BlogCard
component, let’s do that below
import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { formatDate, truncateText } from "../../utils/helperFunctions";
import { deletePost } from "../../utils/server";
export interface IBlogPost {
id: string;
title: string;
url: string;
date: Date;
body: string;
refresh?: () => void;
}
const BlogCard: React.FC<IBlogPost> = ({
id,
title,
body,
url,
date,
refresh,
}) => {
const formattedDate = formatDate(date);
const content = truncateText(body, 250);
const handleDelete = () => {
const yes = window.confirm("Are you sure you want to delete this article?");
yes && deletePost(id);
refresh && refresh();
};
In the code above, we created an interface for our BlogCard
component, imported React-Markdown and the plugins we installed earlier and also helper functions to delete the post, format the post date and truncate the post text.
gfm
is a remark plugin that adds support for strikethrough, table, tasklist and URLsrehypeRaw
makes react-markdown parse html incase we pass html elements inbetween markdown text. This is dangerous and usually not adviceable as it defeats the purpose of react-markdown not rerendering html to prevent html injection but for the purpose of of learning we will use it.
Next, let’s complete our component with the code below:
return (
<section
style={{ borderColor: "#bbb" }}
className="border rounded-md p-4 relative"
>
<div className="controls flex absolute right-4 top-3">
<Link
title="Edit this article"
className="block mr-5"
to={`/edit-article/${id}`}
>
<i className="far fa-edit" />
</Link>
<span
role="button"
onClick={handleDelete}
title="Delete this article"
className="block"
>
<i className="fas fa-trash hover:text-red-700" />
</span>
</div>
<h3 className="text-3xl font-bold mb-3">{title}</h3>
<div className="opacity-80">
<ReactMarkdown
remarkPlugins={[gfm]}
rehypePlugins={[rehypeRaw]}
className="prose"
children={content}
/>
<Link
className="text-blue-500 text-sm underline hover:opacity-80"
to={`${url}`}
>
Read more
</Link>
</div>
<p className="mt-4">{formattedDate}</p>
</section>
);
};
export default BlogCard;
Note that the blogpost body we want to render with react-markdown
is passed to the children
prop and the other plugins added. The className prose
is from tailwind, we get it from the tailwindcss/typography plugin we installed and added to our tailwind config to provide support for markdown styles.
We will add a fontawesome CDN link so we can use fontawesome icons for delete and edit as we have in lines 12 and 13 of the bove code block.
Navigate to your index.html
file in public
folder and replace the file’s content with the code block below.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="Markdown Blog"
content="A markdown blog built with React, TS and react-markdown"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- Fontawesome CDN link here -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"
integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />
<!-- End of Fontawesome CDN link here -->
<title>Markdown blog</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
This Card will preview each post, give a link to the user to view all contents of the post and from here a user can delete a post or navigate to edit-post page. Now that we’ve created this component, we will go ahead to create pages for our application using these components.
Building Home Page
The Home
page will contain all the posts in the app, we will also add a ‘no data’ state for when there are no posts to show. To do this, first create a new folder called pages
in our project src
file, and inside it create another directory called home
, in here create a new file called index.tsx
.
// src/pages/home/index.tsx
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import BlogCard, { IBlogPost } from "../../components/BlogCard/BlogCard";
import { getPosts } from "../../utils/server";
const Homepage = () => {
const [articles, setArticles] = useState<IBlogPost[]>([]);
const [refresh, setRefresh] = useState(false);
useEffect(() => {
const posts = getPosts();
setArticles(posts);
}, [refresh]);
In the code above, we imported the useEffect
and useState
from react
, next we imported Link
from react-router
this will help us navigate to write an article
page. We also imported our BlogCard
component alongside IB``logPost
interface, to render out each post from the array of posts. Finally, we imported the getPosts
object from our utils
directory.
Inside the useEffect
hook, we are fetching all posts we have saved to localStorage and adding the array of posts to our component state (useState
), we also created a state which will enable us to make the useEffect refetch posts whenever a post is deleted.
Next, let’s render our Home
page using the code block below:
return (
<div className="mt-8 mb-20 w-3/5 mx-auto">
<h1 className="mb-6 text-xl">Welcome back, Fortune</h1>
<section className="articles mt-4">
{articles?.length ? (
articles.map((article) => (
<article key={article?.id} className="my-4">
<BlogCard
title={article?.title}
id={article?.id}
url={`/article/${article?.id}`}
body={article?.body}
date={article?.date}
refresh={() => setRefresh(!refresh)}
/>
</article>
))
) : (
<div className="mt-20 flex flex-col items-center justify-center">
<h2 className="text-2xl">No article right now.</h2>
<Link className="block text-blue-500 underline text-sm mt-6" to="/write-article">Add article</Link>
</div>
)}
</section>
</div>
);
};
export default Homepage;
In the code block above, we rendered our Home
page with styles using tailwind classes, we mapped through the array of posts and rendered them with our BlogCard
component and also added a no data state incase there are posts to render.
Building Post Page
In this section, we will create a page for a user to post a new blog post. Inside your pages
directory, create a new folder called post and inside it create a new file named index.tsx
, add the code block below:
import { useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { formatDate } from "../../utils/helperFunctions";
import { getPosts } from "../../utils/server";
import { IBlogPost } from "../../components/BlogCard/BlogCard";
We have now imported different components we’d need for building our page. Next, we will be getting an id
param from our url with useParams
, rendering our post body with ReactMarkdown. let’s do that in the code block below
const Blog = () => {
const { id } = useParams<{ id: string }>();
const post = getPosts().find((post: IBlogPost) => post.id === id);
In the code block above, we created a Blog
component and added retrieving the post id from the url to get the post from all posts (line 4). Next, we will render our post content below
return (
<div className="w-4/5 mx-auto mt-16 mb-24">
{post ? (
<>
<header
style={{ background: "#1C313A" }}
className="rounded-md mb-10 max-w-9/12 py-12 px-20"
>
<h1 className="text-2xl text-center font-semibold uppercase">
{post?.title}
</h1>
<p className="mt-4 text-sm text-center">{formatDate(post?.date as Date)}</p>
</header>
<ReactMarkdown
className="prose"
remarkPlugins={[gfm]}
rehypePlugins={[rehypeRaw]}
children={post?.body as string}
/>
</>
) : (
<h3>Post not found!</h3>
)}
</div>
);
};
export default Blog;
In the code above, we are checking if a post with the id passed to the url is found, if found we show the post title, date and render the text with ReactMarkdown. if the post is not found we render ‘post not found’. If done correctly, your application should look like the image below
Open Source Session Replay
OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.
Start enjoying your debugging experience - start using OpenReplay for free.
Adding Write-A-Post Page
In this section, we will create a page for writing and editing a post, To do this, create a new folder write
inside your pages
folder and inside it create a index.tsx
file and add the code snippet below
import { useEffect } from "react";
import { useState } from "react";
import { useHistory, useParams } from "react-router";
import { editPost, getPosts, savePost } from "../../utils/server";
import { IBlogPost } from "../../components/BlogCard/BlogCard";
const WriteAnArticle = () => {
const { id } = useParams<{ id: string }>();
const history = useHistory();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
useEffect(() => {
if (id) {
const post = getPosts().find((post: IBlogPost) => post.id === id);
post && setTitle(post.title);
post && setBody(post.body);
}
}, [id]);
This component handles both editing and adding posts, so in case of edit we use the id of the post which we will get with useParams
from the url of the page. In the code block above, we initializing a history variable from useHistory
with which we will navigate the user to the home page after editing or adding new posts. We also have state to hold our post title and body from inputs.
In the useEffect, if there’s an id in the URL we’re getting the post to be editted from the array of posts and setting it to our component states so that the values show up in our inputs.
Next, we will create a submitHandler
function to enable the user to submit a post or save the edits performed.
const submitHandler = (e: { preventDefault: () => void }) => {
e.preventDefault();
const post = getPosts().find((post: IBlogPost) => post.id === id);
if (!id) {
const post = {
title,
body,
date: new Date(),
id: new Date().getTime().toString(),
};
savePost(post);
} else if (id && post) {
const updatedPost = {
...post,
title,
body,
};
editPost(updatedPost);
}
history.push("/");
};
In the code above, we are handling form submit, if the id deosn’t exist we save a new post with savePost
helper function, using the title and body from the form and the id and date as the current timestamp of that moment. else we update the post title, body.
We will render our component body below;
return (
<div className="w-3/5 mx-auto mt-12 mb-28">
<h3 className="text-3xl text-center capitalize mb-10 tracking-widest">
Write a post for your blog from here
</h3>
<form onSubmit={submitHandler} className="w-10/12 mx-auto">
<input
className="w-full px-4 mb-6 block rounded-md"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter article title"
/>
<textarea
className="w-full px-4 pt-4 block rounded-md"
name="post-body"
id="post-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Enter article body. you can use markdown syntax!"
/>
<button
title={!body || !title ? "Fill form completely" : "Submit form"}
disabled={!body || !title}
className="block rounded mt-8"
type="submit"
>
Submit Post
</button>
</form>
</div>
);
};
export default WriteAnArticle;
If done correctly, our application should look like the image below
Concluding our Application
To conclude our application, navigate to your App.tsx
file in our src
folder and add the code snippet below
import { BrowserRouter, Switch, Route } from "react-router-dom";
import Navbar from "./components/Navbar/Navbar";
import Post from "./pages/post";
import Homepage from "./pages/home";
import WriteAnArticle from "./pages/write";
import Profile from "./pages/profile";
const App = () => {
return (
<>
<BrowserRouter>
<Navbar />
<Switch>
<Route path="/" exact component={Homepage} />
<Route path="/article/:id" exact component={Post} />
<Route path="/write-article" exact component={WriteAnArticle} />
<Route path="/edit-article/:id" exact component={WriteAnArticle} />
</Switch>
</BrowserRouter>
</>
);
};
export default App;
The code above defines routes for all pages in our app,
/
will navigate to the homepage/article/:id
will navigate to a Post page where we view a post completely. It’s a dynamic route and we will get the id from our url in the Post page./write-article
will navigate to write article page/edit-article/:id
wll take us to edit post page
We also added our Navbar component, if done correctly our application homepage should look like the image below.
You can now go ahead and test the app with markdown syntax and extend it if you want.
Conclusion
In this tutorial, we looked at markdown, react-markdown, we learned how to install and render markdown safely with React markdown. We also reviewed how to use remark-gfm
to extend react-markdown features and also how to make markdown styles display correcty in apps using tailwind.
You can learn more by looking at react markdown’s official documentation.