Jacob Paris
← Back to all content

Building a markdown input with a preview tab (like GitHub and Stack Overflow) with Remix

Markdown is meant to be a simple way to write rich text in plain text. Most of its formatting is common sense: * for italics, ** for bold, # for headers, and so on. It's a great way to write content that's easy to read and write.

But then you try to add links or images, and the sensibility starts to break down. Square brackets for the text, parentheses for the URL, and a ! in front of the brackets for images.

Certainly, being able to preview your markdown to make sure you wrote it correctly is a good idea. GitHub offers this. StackOverflow does as well. Why not join them?

Rendering markdown into HTML

There are a lot of markdown parsers out there, and they are all more or less interchangeable. The one I like is femark on account of it being insanely fast.

Despite its speed, we still don't want to render the same markdown more than once. This is a good use-case for a server-side cache. If we try to render the same markdown twice, we can just return the cached result.

Create a new file markdown.server.ts and add the following code.

ts
import { processMarkdownToHtml as processMarkdownToHtmlImpl } from "@benwis/femark"
import Lrucache from "lru-cache"
const cache = new Lrucache({
ttl: 1000 * 60,
maxSize: 1024,
sizeCalculation: (value) =>
Buffer.byteLength(JSON.stringify(value)),
})
function cachify<TArgs, TReturn>(
fn: (args: TArgs) => TReturn,
) {
return function (args: TArgs): TReturn {
if (cache.has(args)) {
return cache.get(args) as TReturn
}
const result = fn(args)
cache.set(args, result)
return result
}
}
export const processMarkdownToHtml = cachify(
processMarkdownToHtmlImpl,
)

This will power the endpoint that we'll use to render markdown into HTML. Create a new action in a route of your choice and add the following code.

ts
import { processMarkdownToHtml } from "./markdown.server.ts"
export async function action({
params,
request,
}: ActionFunctionArgs) {
const formData = await request.formData()
const description = formData.get("description") || ""
const html = processMarkdownToHtml(
description.toString().trim(),
)
// Optionally, store this in a database
// const id = params.id as string
// db[id].description = description.toString()
// db[id].preview = html.content
return new Response(html.content, {
status: 200,
headers: {
"Content-Type": "text/html",
},
})
}

Sending markdown to this endpoint should successfully return the HTML.

Thinking in progressive enhancement

Progressive enhancement means our page should be interactive before javascript loads on the client, to make it compatible with devices that have javascript disabled or when javascript fails to load for one of many reasons.

If we were building this without javascript, what would that look like?

  • The textarea would need to be a form and have a submit button
  • The tabs would need to be links that set a query parameter to ?tab=edit or ?tab=preview
  • The preview would need to be rendered server-side

Those requirements set the general approach, and then we can think about what javascript would add to the experience.

  • When the user finishes typing, send the markdown to the server to render the preview automatically
  • Update the preview tab with the up-to-date content
  • We can change tabs client-side without a page reload

Writing an auto-submitting form

The best way to make a form submit automatically is with Remix's useFetcher() hook.

Use fetcher.Form and add a callback to make it submit on change.

Don't forget to include a regular submit button so that the form can be submitted without javascript.

tsx
export default function Example() {
const fetcher = useFetcher()
return (
<div>
<fetcher.Form
method="POST"
onChange={(e) => {
fetcher.submit(e.currentTarget, {
replace: true,
})
}}
>
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
rows={8}
defaultValue={description || ""}
/>
<div>
<button type="submit">Save</button>
</div>
</fetcher.Form>
</div>
)
}

Displaying the HTML preview

We can use the fetcher.data property to get the response from the server.

Some markdown solutions will just return JSON you can pass directly into a React component, but femark returns HTML. We can use dangerouslySetInnerHTML to render it.

tsx
export default function Example() {
const fetcher = useFetcher()
return (
<div>
{/* Previous code for the fetcher.Form */}
<div
dangerouslySetInnerHTML={{
__html: fetcher.data,
}}
/>
</div>
)
}

Tabbing between edit and preview mode

The tabs will be simple links to the same page with the query parameter changed.

tsx
export default function Example() {
const fetcher = useFetcher()
const [searchParams] = useSearchParams({ tab: "edit" })
return (
<div>
<div>
<Link to="?tab=edit">Edit</Link>
<Link to="?tab=preview"> Preview </Link>
</div>
{searchParams.get("tab") === "edit" ? (
<fetcher.Form />
) : (
<div
dangerouslySetInnerHTML={{
__html: fetcher.data,
}}
/>
)}
</div>
)
}

Depending on how you've built out your project, this will probably be quite slow. On every navigation, Remix is running the matching loaders to revalidate the data.

Since we know there's nothing happening on these tab changes that requires revalidating every upstream loader, we can tell Remix not to run the loaders again.

Create a shouldRevalidate function in your route, and return false if the only changes have been the query parameters.

tsx
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
}) => {
if (currentUrl.pathname === nextUrl.pathname) {
return false
}
return true
}
Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.