OpenReplay
Navigate back to the homepage
BLOG
Browse Repo
Back

How to Safely Render Markdown From a React Component

Fortune Ikechi
July 30th, 2021 · 8 min read

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

1npx 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

1yarn 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.

1npx 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

1module.exports = {
2 purge: [],
3 darkMode: false, // or 'media' or 'class'
4 theme: {
5 extend: {
6 typography: {
7 DEFAULT: {
8 css: {
9 color: "#FFF",
10 a: {
11 color: "#4798C5",
12 "&:hover": {
13 color: "#2c5282",
14 },
15 },
16 h1: {
17 color: "#FFF",
18 },
19 h2: {
20 color: "#FFF",
21 },
22 h3: {
23 color: "#FFF",
24 },
25 h4: { color: "#FFF" },
26 em: { color: "#FFF" },
27 strong: { color: "#FFF" },
28 blockquote: { color: "#FFF" },
29 code: { backgroundColor: "#1A1E22", color: "#FFF" },
30 },
31 },
32 },
33 },
34 },
35 variants: {
36 extend: {},
37 },
38 plugins: [require("@tailwindcss/typography")],
39};

Run the below command to generate a postcss config file in your project directory

1touch postcss.config.js

Add the code below in the file;

1const tailwindcss = require('tailwindcss');
2module.exports = {
3 plugins: [
4 tailwindcss('./tailwind.config.js'),
5 require('autoprefixer')
6 ],
7};

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

1@import "tailwindcss/base";
2@import "tailwindcss/components";
3@import "tailwindcss/utilities";

Update your scripts in package.json to build tailwind styles when the dev server starts

1"scripts": {
2 "start": "npm run watch:css && react-scripts start",
3 "build": "npm run watch:css && react-scripts build",
4 "test": "react-scripts test",
5 "eject": "react-scripts eject",
6 "watch:css": "postcss src/styles/tailwind.css -o src/styles/main.css"
7}

Lastly, add main.css to src/index.ts, the file should like this;

1import React from "react";
2import ReactDOM from "react-dom";
3import "./styles/main.css";
4import "./index.css";
5import App from "./App";
6
7ReactDOM.render(
8 <React.StrictMode>
9 <App />
10 </React.StrictMode>,
11 document.getElementById("root")
12);

To start our development server, let’s start our development server using the command below

1yarn start

OR using NPM

1npm 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.

1yarn add react-router-dom remark-gfm rehype-raw

Inside this file, let’s create our Navbar with the code block below:

1import { Link, NavLink } from "react-router-dom";
2const Navbar = () => {
3 return (
4 <nav className="mt-6 w-10/12 mx-auto flex justify-between items-center">
5 <Link
6 style={{ fontStyle: "oblique" }}
7 className="text-xl tracking-widest logo"
8 to="/"
9 >
10 Markdown Blog
11 </Link>
12 <div className="flex-items-center">
13 <NavLink
14 activeClassName="border-b-2"
15 className="mr-6 tracking-wider"
16 to="/write-article"
17 exact
18 >
19 Write An Article
20 </NavLink>
21 <NavLink
22 activeClassName="border-b-2"
23 className="tracking-wider"
24 to="/profile"
25 exact
26 >
27 Profile
28 </NavLink>
29 </div>
30 </nav>
31 );
32};
33export 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:

  1. Delete, edit, save and fetch posts from localStorage (our DB).
  2. Format the date of the post.
  3. 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.

1import moment from "moment";
2
3export const truncateText = (text: string, maxNum: number) => {
4 const textLength = text.length;
5 return textLength > maxNum ? `${text.slice(0, maxNum)}...` : text;
6};
7
8export const formatDate = (date: Date) => {
9 return moment(date).format("Do MMM YYYY, h:mm:ss a");
10};

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

1import { IBlogPost } from "../components/BlogCard/BlogCard";
2
3export const savePost = (post: Partial<IBlogPost>) => {
4 if (!localStorage.getItem("markdown-blog")) {
5 localStorage.setItem("markdown-blog", JSON.stringify([post]));
6 } else {
7 const posts = JSON.parse(localStorage.getItem("markdown-blog") as string);
8 localStorage.setItem("markdown-blog", JSON.stringify([post, ...posts]));
9 }
10};
11
12export const editPost = (newPostContent: IBlogPost) => {
13 const posts: IBlogPost[] = JSON.parse(
14 localStorage.getItem("markdown-blog") as string
15 );
16 const postIdx = posts.findIndex((post) => post.id === newPostContent.id);
17 posts.splice(postIdx, 1, newPostContent);
18 localStorage.setItem("markdown-blog", JSON.stringify(posts));
19};

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.

1export const getPosts = () => {
2 if (!localStorage.getItem("markdown-blog")) {
3 return [];
4 } else {
5 const posts = JSON.parse(localStorage.getItem("markdown-blog") as string);
6 return posts;
7 }
8};
9
10export const deletePost = (id: string) => {
11 const posts: IBlogPost[] = JSON.parse(
12 localStorage.getItem("markdown-blog") as string
13 );
14 const newPostList = posts.filter((post) => post.id !== id);
15 localStorage.setItem("markdown-blog", JSON.stringify(newPostList));
16};

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

1import { Link } from "react-router-dom";
2import ReactMarkdown from "react-markdown";
3import gfm from "remark-gfm";
4import rehypeRaw from "rehype-raw";
5import { formatDate, truncateText } from "../../utils/helperFunctions";
6import { deletePost } from "../../utils/server";
7
8export interface IBlogPost {
9 id: string;
10 title: string;
11 url: string;
12 date: Date;
13 body: string;
14 refresh?: () => void;
15}
16const BlogCard: React.FC<IBlogPost> = ({
17 id,
18 title,
19 body,
20 url,
21 date,
22 refresh,
23}) => {
24 const formattedDate = formatDate(date);
25 const content = truncateText(body, 250);
26 const handleDelete = () => {
27 const yes = window.confirm("Are you sure you want to delete this article?");
28 yes && deletePost(id);
29 refresh && refresh();
30 };

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 URLs
  • rehypeRaw 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:

1return (
2 <section
3 style={{ borderColor: "#bbb" }}
4 className="border rounded-md p-4 relative"
5 >
6 <div className="controls flex absolute right-4 top-3">
7 <Link
8 title="Edit this article"
9 className="block mr-5"
10 to={`/edit-article/${id}`}
11 >
12 <i className="far fa-edit" />
13 </Link>
14 <span
15 role="button"
16 onClick={handleDelete}
17 title="Delete this article"
18 className="block"
19 >
20 <i className="fas fa-trash hover:text-red-700" />
21 </span>
22 </div>
23 <h3 className="text-3xl font-bold mb-3">{title}</h3>
24 <div className="opacity-80">
25 <ReactMarkdown
26 remarkPlugins={[gfm]}
27 rehypePlugins={[rehypeRaw]}
28 className="prose"
29 children={content}
30 />
31 &nbsp;
32 <Link
33 className="text-blue-500 text-sm underline hover:opacity-80"
34 to={`${url}`}
35 >
36 Read more
37 </Link>
38 </div>
39 <p className="mt-4">{formattedDate}</p>
40 </section>
41);
42};
43export 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.

1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6 <meta name="viewport" content="width=device-width, initial-scale=1" />
7 <meta name="theme-color" content="#000000" />
8 <meta
9 name="Markdown Blog"
10 content="A markdown blog built with React, TS and react-markdown"
11 />
12 <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13 <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
14 <!-- Fontawesome CDN link here -->
15 <link
16 rel="stylesheet"
17 href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"
18 integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w=="
19 crossorigin="anonymous"
20 referrerpolicy="no-referrer" />
21 <!-- End of Fontawesome CDN link here -->
22 <title>Markdown blog</title>
23 </head>
24 <body>
25 <noscript>You need to enable JavaScript to run this app.</noscript>
26 <div id="root"></div>
27 </body>
28</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.

1// src/pages/home/index.tsx
2
3import { useEffect, useState } from "react";
4import { Link } from "react-router-dom";
5import BlogCard, { IBlogPost } from "../../components/BlogCard/BlogCard";
6import { getPosts } from "../../utils/server";
7
8const Homepage = () => {
9 const [articles, setArticles] = useState<IBlogPost[]>([]);
10 const [refresh, setRefresh] = useState(false);
11
12 useEffect(() => {
13 const posts = getPosts();
14 setArticles(posts);
15 }, [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:

1return (
2 <div className="mt-8 mb-20 w-3/5 mx-auto">
3 <h1 className="mb-6 text-xl">Welcome back, Fortune</h1>
4 <section className="articles mt-4">
5 {articles?.length ? (
6 articles.map((article) => (
7 <article key={article?.id} className="my-4">
8 <BlogCard
9 title={article?.title}
10 id={article?.id}
11 url={`/article/${article?.id}`}
12 body={article?.body}
13 date={article?.date}
14 refresh={() => setRefresh(!refresh)}
15 />
16 </article>
17 ))
18 ) : (
19 <div className="mt-20 flex flex-col items-center justify-center">
20 <h2 className="text-2xl">No article right now.</h2>
21 <Link className="block text-blue-500 underline text-sm mt-6" to="/write-article">Add article</Link>
22 </div>
23 )}
24 </section>
25 </div>
26 );
27};
28
29export 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:

1import { useParams } from "react-router-dom";
2import ReactMarkdown from "react-markdown";
3import gfm from "remark-gfm";
4import rehypeRaw from "rehype-raw";
5import { formatDate } from "../../utils/helperFunctions";
6import { getPosts } from "../../utils/server";
7import { 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

1const Blog = () => {
2 const { id } = useParams<{ id: string }>();
3
4 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

1return (
2 <div className="w-4/5 mx-auto mt-16 mb-24">
3 {post ? (
4 <>
5 <header
6 style={{ background: "#1C313A" }}
7 className="rounded-md mb-10 max-w-9/12 py-12 px-20"
8 >
9 <h1 className="text-2xl text-center font-semibold uppercase">
10 {post?.title}
11 </h1>
12 <p className="mt-4 text-sm text-center">{formatDate(post?.date as Date)}</p>
13 </header>
14
15 <ReactMarkdown
16 className="prose"
17 remarkPlugins={[gfm]}
18 rehypePlugins={[rehypeRaw]}
19 children={post?.body as string}
20 />
21 </>
22 ) : (
23 <h3>Post not found!</h3>
24 )}
25 </div>
26 );
27};
28export 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

rendered text

Open Source Session Replay

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue. It’s like having your browser’s inspector open while looking over your user’s shoulder. OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app 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

1import { useEffect } from "react";
2import { useState } from "react";
3import { useHistory, useParams } from "react-router";
4import { editPost, getPosts, savePost } from "../../utils/server";
5import { IBlogPost } from "../../components/BlogCard/BlogCard";
6
7const WriteAnArticle = () => {
8 const { id } = useParams<{ id: string }>();
9 const history = useHistory();
10 const [title, setTitle] = useState("");
11 const [body, setBody] = useState("");
12
13 useEffect(() => {
14 if (id) {
15 const post = getPosts().find((post: IBlogPost) => post.id === id);
16 post && setTitle(post.title);
17 post && setBody(post.body);
18 }
19 }, [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.

1const submitHandler = (e: { preventDefault: () => void }) => {
2 e.preventDefault();
3
4 const post = getPosts().find((post: IBlogPost) => post.id === id);
5 if (!id) {
6 const post = {
7 title,
8 body,
9 date: new Date(),
10 id: new Date().getTime().toString(),
11 };
12 savePost(post);
13 } else if (id && post) {
14 const updatedPost = {
15 ...post,
16 title,
17 body,
18 };
19 editPost(updatedPost);
20 }
21 history.push("/");
22 };

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;

1return (
2 <div className="w-3/5 mx-auto mt-12 mb-28">
3 <h3 className="text-3xl text-center capitalize mb-10 tracking-widest">
4 Write a post for your blog from here
5 </h3>
6 <form onSubmit={submitHandler} className="w-10/12 mx-auto">
7 <input
8 className="w-full px-4 mb-6 block rounded-md"
9 type="text"
10 value={title}
11 onChange={(e) => setTitle(e.target.value)}
12 placeholder="Enter article title"
13 />
14 <textarea
15 className="w-full px-4 pt-4 block rounded-md"
16 name="post-body"
17 id="post-body"
18 value={body}
19 onChange={(e) => setBody(e.target.value)}
20 placeholder="Enter article body. you can use markdown syntax!"
21 />
22 <button
23 title={!body || !title ? "Fill form completely" : "Submit form"}
24 disabled={!body || !title}
25 className="block rounded mt-8"
26 type="submit"
27 >
28 Submit Post
29 </button>
30 </form>
31 </div>
32 );
33};
34export default WriteAnArticle;

If done correctly, our application should look like the image below

Form to edit or submit an article/post in our app

Concluding our Application

To conclude our application, navigate to your App.tsx file in our src folder and add the code snippet below

1import { BrowserRouter, Switch, Route } from "react-router-dom";
2import Navbar from "./components/Navbar/Navbar";
3import Post from "./pages/post";
4import Homepage from "./pages/home";
5import WriteAnArticle from "./pages/write";
6import Profile from "./pages/profile";
7const App = () => {
8 return (
9 <>
10 <BrowserRouter>
11 <Navbar />
12 <Switch>
13 <Route path="/" exact component={Homepage} />
14 <Route path="/article/:id" exact component={Post} />
15 <Route path="/write-article" exact component={WriteAnArticle} />
16 <Route path="/edit-article/:id" exact component={WriteAnArticle} />
17 </Switch>
18 </BrowserRouter>
19 </>
20 );
21};
22export 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.

React markdown

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.

More articles from OpenReplay Blog

The Complete Guide to Localizing your App with JavaScript's Internationalization API

Internationalizaton is Easy or so they say. Learn how to use the internationalization API from JavaScript

July 30th, 2021 · 9 min read

Everything You Ever Wanted to Know About WebRTC

A primer around WebRTC with everything you need to get started

July 27th, 2021 · 7 min read
© 2021 OpenReplay Blog
Link to $https://twitter.com/OpenReplayHQLink to $https://github.com/openreplay/openreplayLink to $https://www.linkedin.com/company/18257552