How to add Code blocks & Markdown to the Framer CMS
We recently made the switch from Webflow to Framer for the Basedash website. A big reason for that switch was the need to have a better CMS for our blog content. We write a ton of technical posts here and use Notion to compose and collaborate around them. We also use Notion for tracking our goals, taking notes, capturing user interviews, and we even have our docs in Notion running on a Super site so that we can keep it up-to-date without much effort. There may be better authoring experiences out there, but we don’t want to add more tools to our stack if we don’t have to.
There also may be more specific CMSs out there, that are made for technical posts, but I wanted to make sure that our site was unified with our Marketing content and had as much flexibility as possible, so tools that didn’t work with Framer were out of the running.
The old workflow in Webflow
Previously in Webflow, we could copy the page content over to their CMS, fill in the fields, but a lot of the rich content like callouts, code blocks, inline code, and media embeds didn’t work well. I’d have to manually copy each image from Notion, and then upload it to the Webflow CMS, add custom code blocks for each code snippet, add opening and closing tags to those snippets so they’d render, and then find any in-line snippets
and bold them so that our CMS block component would make them look like this
. It was a major pain. Probably about 30 minutes per blog post.
Also, then end result wasn’t great, even with all of that manual work. Code embeds were iffy, rich content like tweets and external embeds were hard to control, and, if I’m being honest, the act of editing content itself in the Webflow CMS was totally frustrating. Notion and newer tools have spoiled me with / menus, keyboard shortcuts and dropping the need to save my content constantly.
Here’s a tweet showing what that looked like:
Open tweet->
I loved the ease of editing data in the Framer CMS, the fact that I didn’t have to kick my teammates out when I needed to work, but the Framer rich text component didn’t give me everything I wanted.
The best part about Framer though, is that I’m not limited to their components. We can build our own.
How it’s set up in Framer
So, we decided to write a custom markdown component using react-markdown, and make the technical post component we’ve always wanted without relying on the CMS or site builder we were using to add support for features we want. This way we can improve it over time as our blog needs change. The component then renders that content without even needing to use the built in rich text block that Framer provides.
Here’s what that CMS collection looks like in Framer:
Basically, if you can’t watch the video I made, we export the content from Notion (more on that later) and add it to a plan text
field in Framer called post
.
Then we paste the markdown into that post and add all of the other content like SEO tags, image, image alt text, description, etc. There’s no automated way I’ve found to do this, but overall it’s down to about a minute from ready to publish → live.
Also, because we’re authoring the content inside of Notion, we use this AI based prompt to generate the summary (for the list page), SEO title tag, and a meta description for each. Also a huge time saver. I’m sure other tools will add things like this over time, but Notion has it now and so do we.
Notion AI prompt:
Give me a 200 character summary, a title tag for SEO, and a meta description for SEO of this blog post.
And without further ado, here’s the full Markdown code we are using for the blog content:
import React, { useState } from "react" import ReactMarkdown from "https://esm.sh/react-markdown@7?conditions=worker&bundle" import { Prism as SyntaxHighlighter } from "https://esm.sh/react-syntax-highlighter@15" import remarkGfm from "https://esm.sh/remark-gfm@3?conditions=worker&bundle" import TweetEmbed from "https://esm.sh/react-tweet-embed" import ReactDom from "react-dom" import { addPropertyControls, ControlType } from "framer" // Available styles: https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_STYLES_PRISM.MD import { atomDark as style } from "https://esm.sh/react-syntax-highlighter@15/dist/esm/styles/prism" export default function Markdown(props) { // Necessary for optimization: https://gist.github.com/heypiotr/602ca88b9991ac9df2211d74598638ef#file-framer-optim-1-jsx // https://www.framer.community/c/support/optimization-errors const [isLoading, setLoading] = React.useState(false) React.useEffect(() => setLoading(false), []) if (isLoading) { return props.content } return ( <ReactMarkdown children={props.content} className="blog-post" remarkPlugins={[remarkGfm]} components={{ h1(props) { return ( <h1 style={{ fontFamily: "Spezia Extended Web Black", fontSize: 40, marginTop: "3rem", marginBottom: "0.5rem", }} > {props.children} </h1> ) }, h2(props) { return ( <h2 style={{ fontFamily: "Spezia Extended Web Black", fontSize: 28, marginTop: "2rem", marginBottom: "0.5rem", }} > {props.children} </h2> ) }, h3(props) { return ( <h3 style={{ fontFamily: "Spezia Extended Web Black", fontSize: 20, marginTop: "1rem", marginBottom: "0.5rem", }} > {props.children} </h3> ) }, p(props) { return ( <p style={{ fontFamily: "Inter", fontSize: 16, margin: "21px 0", lineHeight: 1.5, }} > {props.children} </p> ) }, ul(props) { return ( <ul style={{ fontFamily: "Inter", fontSize: 16, margin: "21px 0", lineHeight: 1.5, }} > {props.children} </ul> ) }, ol(props) { return ( <ol style={{ fontFamily: "Inter", fontSize: 16, margin: "21px 0", lineHeight: 1.5, }} > {props.children} </ol> ) }, a(props) { if ( props.href.match( /^https?:\/\/twitter.com\/(.+)\/(\d+)/g ) ) { const tweetId = props.href.split("/").at(-1) return <TweetEmbed tweetId={tweetId} /> } return ( <a href={props.href} style={{ fontFamily: "Inter", fontSize: 16, color: "#3a50fc", textDecoration: "underline", }} > {props.children} </a> ) }, li(props) { return ( <li style={{ fontFamily: "Inter", fontSize: 16, }} > {props.children} </li> ) }, img(props) { if (props.src.endsWith(".mp4")) { return ( <video controls className="blog-post-video" style={{ display: "block", margin: "0 auto", maxWidth: "100%", }} title={props.alt} > <source src={props.src} type="video/mp4" /> </video> ) } return ( <img className="blog-post-image" style={{ display: "block", margin: "0 auto", maxWidth: "100%", }} src={props.src} alt={props.alt} /> ) }, blockquote(props) { return ( <blockquote style={{ margin: 0, marginBottom: "1rem", fontSize: "12px", background: "rgba(21, 21, 21, 0.04)", padding: "1px 1rem ", // Non-zero vertical padding is a hack borderLeft: "1px dotted #A3A3A3", }} > {props.children} </blockquote> ) }, code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || "") const [copySuccess, setCopySuccess] = useState(false) function handleCopy() { navigator.clipboard.writeText( String(children[0]).replace(/\n$/, "") ) setCopySuccess(true) setTimeout(() => { setCopySuccess(false) }, 1500) } return !inline && match ? ( <div className="code-block-wrapper" style={{ position: "relative" }} > <button className="copy-text-button" style={{ position: "absolute", top: "8px", right: "8px", background: "#151515", border: "1px solid rgba(255, 255, 255, 0.15)", borderRadius: "4px", color: "white", fontFamily: "Inter", fontSize: "14px", padding: "4px 8px 4px 24px", cursor: "pointer", transition: "background-color 50ms ease-in-out", // CSS on the blog pages makes the button visible when hovering over the code block //visibility: "hidden", }} onClick={handleCopy} > <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ position: "absolute", left: "6px", top: "5px", }} > <g opacity="0.6"> <path fillRule="evenodd" clipRule="evenodd" d="M2 2.5C2 2.22386 2.22386 2 2.5 2H9.5C9.77614 2 10 2.22386 10 2.5V9.5C10 9.77614 9.77614 10 9.5 10H2.5C2.22386 10 2 9.77614 2 9.5V2.5ZM3 3V9H9V3H3ZM11 5.5C11 5.22386 11.2239 5 11.5 5H12.5C12.7761 5 13 5.22386 13 5.5V12.5C13 12.7761 12.7761 13 12.5 13H5.5C5.22386 13 5 12.7761 5 12.5V11.5C5 11.2239 5.22386 11 5.5 11C5.77614 11 6 11.2239 6 11.5V12H12V6H11.5C11.2239 6 11 5.77614 11 5.5Z" fill="#ffffff" /> </g> </svg> {copySuccess ? "Text copied!" : "Copy text"} </button> <SyntaxHighlighter style={style} language={match[1]} PreTag="div" customStyle={{ borderRadius: "10px", background: "#151515", paddingTop: "3rem", maxHeight: "80vh", }} // Using a div instead of the default 'code' tag since apparaently Prism will look for code tags at runtime sometimes and cause the code blogs to render as [object Object]. Noticeable on safari, but not chrome. // Found this fix via https://github.com/storybookjs/storybook/pull/18158/files CodeTag="div" {...props} > {children} </SyntaxHighlighter> </div> ) : ( <code className={className} {...props} style={{ background: "#ffe9df", borderRadius: "4px", padding: "1px 4px", color: "#cf521c", fontSize: "14px", fontFamily: "'Jetbrains Mono','Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace", border: "1px solid #fcc9b3", }} > {children} </code> ) }, }} /> ) } Markdown.defaultProps = { content: `# Markdown component [Here's a link](https://new.basedash.com) [Here's Max's Twitter profile](https://twitter.com/MaxMusing) \`[Here's a code link 1](https://new.basedash.com)\` [\`Here's a code link 2\`](https://new.basedash.com) Here's a code block for the \`add\` function: \`\`\`js function add(a, b) { return a + b; } \`\`\` > 💡 Here's a quote ![Video](https://basedash-blog.s3.amazonaws.com/How+to+add+Code+blocks+%26+Markdown+to+the+Framer+CM+2c07a811ce08435f96a7324c33f52001/Framer_Setup.mp4) ### Header Here's a Twitter embed: [https://twitter.com/MaxMusing/status/1600616312165863424](https://twitter.com/MaxMusing/status/1600616312165863424) `, } addPropertyControls(Markdown, { content: { title: "Content", type: ControlType.String, }, })
To use this code, create a custom code component in Framer and then paste it in. Our blog uses some custom fonts, but I’ve replaced those with Inter
so you should be good to go.
Getting content out of Notion
💡 If you are or want to use Notion for composing your blog content, this section will be helpful. If you are authoring with anything else, then this won’t be relevant.
🍎 Also, this is Mac only. Raycast doesn’t work on a PC.
The tricky part about writing content in Notion is the images. Notion hosts all embedded content on their servers, and doesn’t have any way to host them elsewhere. Because of this, if you just copy and paste any image from Notion to Framer, it’ll break. Public Notion pages use a public url, but exported Markdown always has the internal url which can’t be viewed by a non-authenticated user.
So, we had to figure out a quick way to get the Notion images hosted elsewhere.
When you export a Notion page as markdown, if there’s images or other content, it will include those in a separate folder. Here’s this post’s folder structure as an example:
I can manually upload those files somewhere, but the easiest way to do this turned out to be writing a Raycast script to do it for me.
Here’s how we export the content and paste it into Framer:
Open tweet->
Here’s the Raycast extension that we made to help with this:
https://github.com/Basedash/raycast-markdown-image-upload
To use this extension, you’ll have to add your own credentials to your own S3 bucket, but if you have any issues or questions about it, feel free to send a tweet our way.
Here’s how to to install it to Raycast:
After you have the extension installed and pointing at your S3 bucket, you just need to copy the pathname of the root Markdown file (by holding opt+right click), and then running the extension and pasting it in.
After this, run the script and it will upload all of the images to the S3 bucket, and update all of the links in the markdown file to point to the new urls. Crazy fast.
Now that the content is formatted properly, you can paste that markdown content into Framer.
Hope this is helpful, be sure to let us know if you have any questions by tweeting at Basedash or Me and we’ll be sure to lend a hand.
Invite only
We're building the next generation of data visualization.