I built an Markdown editor using Next.js and TailwindCss ๐Ÿ”ฅ

โณ 8 min read

๐Ÿ”  1195 words

I built an Markdown editor using Next.js and TailwindCss ๐Ÿ”ฅ

Join me on this project where we build an online Markdown editor using the latest version of Nextjs.



# Objectives

To check the finished build click here โžš


# 1. Create the landing page

I want a simple layout so I divided the screen into two parts; the left being the editor and we see the markdown rendering on the right.

const Homepage = () => {
	return (
		<div className='h-screen flex justify-between'>
			{/* Input markdown */}
			<section className='w-full pt-5 h-full'>
				<textarea
					className='w-full ... placeholder:opacity-80'
					placeholder='Feed me some Markdown ๐Ÿ•'
					autoFocus
				/>
			</section>

			<div className='fixed ... border-dashed' />

			{/* Render markdown */}
			<article className='w-full pt-5 pl-6'>
				Markdown lies here
			</article>
		</div>
	)
}

export default Homepage


# 2. Add states to store data

Now let's change it into a client component and add the `useState` hook.

'use client';

import { useState } from "react"

const Homepage = () => {
	const [source, setSource] = useState('');

	return (
		...
		<textarea
      className='w-full ... placeholder:opacity-80'
      placeholder='Feed me some Markdown ๐Ÿ•'
      value={source}
      onChange={(e) => setSource(e.target.value)}
      autoFocus
    />
    ...
	)
}

# 3. Setup `react-markdown` and `@tailwindcss/typography`

We use react-markdown โžš to render markdown and @tailwindcss/typography โžš to style the markdown. Install them by firing the following commands.

npm install react-markdown
npm install -D @tailwindcss/typography

Now import and add the Markdown component and pass `source` as children. Remember to add the `prose` classname to the Markdown component.

import Markdown from 'react-markdown'

const Homepage = () => {
	return (
		...
		<div className='fixed ... border-dashed' />
        // Render the markdown
        <article className='w-full pt-5 pl-6'>
          <Markdown
            className='prose prose-invert min-w-full'
          >
            {source}
          </Markdown>
        </article>
        ...
	)
}

Now if you type any markdown you'd still not find any changes. This is because we forgot to add the `@tailwindcss/typography` plugin to the tailwindcss config ๐Ÿ’€

Change your `tailwind.config.ts` to the following:

import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  // Add the plugin here
  plugins: [require('@tailwindcss/typography')],
};

export default config;

Now write some markdown and you will see the changes live ๐Ÿš€


# 4. Code Highlighting and Custom Components

Now we need to install the `react-syntax-highlighter` package to add code highlighting to our project.

npm i react-syntax-highlighter
npm i --save @types/react-syntax-highlighter

Now we are going to create a Custom Component for the Code Highlighter.

Create a folder called `components` inside the `src` folder. Now create a file called `Code.tsx` inside the components folder.

Add the following code from the documentation โžš of react-syntax-highlighter:

import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';

export const CodeBlock = ({ ...props }) => {
  return (
    <SyntaxHighlighter
      language={props.className?.replace(/(?:lang(?:uage)?-)/, '')}
      style={materialOceanic}
      wrapLines={true}
      className='not-prose rounded-md'
    >
      {props.children}
    </SyntaxHighlighter>
  )
}

Here the props contain a classname with the language of the code in the format: `lang-typescript` or sometimes `language-typescript` so we use some regex grooupsto remove everything except the name of the language. The `not-prose` classname is going to remove the default typography styles โžš.

Now comeback to the main `page.tsx` file and import the `CodeBlock` component and pass it to the original `<Markdown />` component

import Markdown from 'react-markdown'
import { CodeBlock } from '@/components/Code'

const Homepage = () => {
	const options = { code: CodeBlock }
	return (
		...
          <Markdown
            className='prose prose-invert min-w-full'
            components={options}
          >
            {source}
          </Markdown>
        ...
	)
}

This is going to replace every occurrence of `code` with our Custom `CodeBlock`component.


(Optional)

BUG (๐Ÿ›): You might have a weird dark border around your code component which is caused by the `pre` tag and tailwind styles.

To fix this go back to your `Code.tsx` and add the following code that removes the tailwind styles from the pre tag.

export const Pre = ({ ...props }) => {
  return (
    <div className='not-prose'>
      {props.children}
    </div>
  )
}

Import this into your `page.tsx` and add it into the `options` variable:

const Homepage = () => {
	const options = {
		code: CodeBlock,
		// Add here
		pre: Pre,
	}
	return ( ... )
}

This is going to remove that border.


# 5. Adding Rehype and Remark Plugins

Rehype โžš and Remark โžš are plugins used to transform and manipulate the HTML and Markdown content of a website, helping to enhance its functionality and appearance.

We are going to use the following:

Install the Plugins:

npm i remark-gfm rehype-external-links rehype-sanitize

Back to our `page.tsx`

import remarkGfm from 'remark-gfm'
import rehypeSanitize from 'rehype-sanitize'
import rehypeExternalLinks from 'rehype-external-links'

...
<Markdown
  ...
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[
	rehypeSanitize,
	[rehypeExternalLinks,
	 { content: { type: 'text', value: '๐Ÿ”—' } }
    ],
  ]}
>{source}</Markdown>

Pass the remark plugins within `remarkPlugins` and rehype plugins in `rehypePlugins` (I know very surprising).

If any plugin needs any customization โžš put them in square brackets followed by the plugin name and the options in curly brackets in this syntax:

`[veryCoolPlugin, { { options } }]`


# 6. Header with Markdown buttons

Next we add a Header component that has buttons which on clicked inserts certain Markdown elements.

First create a `Header.tsx` in the `components` folder and write the following code:

const Header = () => {
  const btns = [
    { name: 'B', syntax: '**Bold**' },
    { name: 'I', syntax: '*Italic*' },
    { name: 'S', syntax: '~Strikethrough~' },
    { name: 'H1', syntax: '# ' },
]

  return (
    <header className="flex ... bg-[#253237]">
        {btns.map(btn => (
          <button
            key={btn.syntax}
            className="flex ...rounded-md"
          >
            {btn.name}
          </button>
        ))}
    </header>
  )
}

export default Header

Import it in the main `page.tsx`

import Header from '@/components/Header'

const Homepage = () => {
	const options = { code: CodeBlock }
	return (
		<>
		<Header /> // Should be on top
		<div className='h-screen flex justify-between'>
			...
		</div>
		</>
	)
}

Now here's the catch. Our states lie in the parent component and the `Header` is a child component.

How do we work with the states in the child component? The best solution is we create a function to change the state in parent component and pass the function to the child component. Read this article โžš

const Homepage = () => {
  const [source, setSource] = useState('');

  const feedElement = (syntax: string) => {
    return setSource(source + syntax)
  }

  return (
	 <>
	 <Header />
	...
  )
}

In `Header.tsx` we need to accept the function as a parameter and add it to the button using the `onClick` attribute:

const Header = (
  { feedElement }:
  { feedElement: (syntax: string) => void }
) => {
  const btns = [ ... ]

  return (
    ...
    <button
      key={btn.syntax}
      className="flex ...rounded-md"
      onClick={() => feedElement(btn.syntax)}
    >
      {btn.name}
    </button>
  )
}

Back to `page.tsx` we pass the `feedElement` function to the Header

const feedElement = (syntax: string) => {
  return setSource(source + syntax)
}

return (
  <>
  <Header feedElement={feedElement} />
  ...
)

Now anytime you click on a button you should get the following Markdown Element.