Spotify just created a playlist with the most listened songs by me during this 2020, so, I took this opportunity to write a tutorial about MDX and Next.js.
MDX is a Markdown language enhancement, making it more powerful and customizable, because MDX allows rendering React components inside of Markdown files.
This tutorial was written using MDX, so, below, you can see an example of a React component rendered in this post.
I'm a React component that uses Tailwind CSS for styling
Setting up Next.js and MDX
I built this website using Next.js, I'm going to talk about how I configured MDX to create the blog section. To accomplish it, this article posted by Sergio Xalambrí was helpful for me.
First of all, I configured MDX with Next.js and in this link you can check out the instructions, it is really easy to complete it.
Then, I installed the next-mdx-remote package. This package allows rendering MDX files that can be hosted on a remote location, for example, a CMS, a CDN, a database or anywhere. There are other reasons for which it is useful to use this package, in this section of its GitHub repository you can dig deep in the problems that next-mdx-remote solves.
Now, I'm going to explain how to use next-mdx-remote for rendering a blog post in a Next.js page depending on a URL param.
First, I created the function where the property is an array of slugs of all my blog posts that I want to pre-render.
I chose the static-site generation approach because I don't have a lot of pages, probably less than 20 and its content does not change often. I recommend reading more about this topic in this Next.js docs page, its explanation is very clear.
Now, inside of the function, the MDX content of all blog posts is readed. This function only executes at build time, I mean, on the server side. At the moment I store all my blog posts MDX files inside of this website repository. In the future, I could store them on a CMS, for example.
Problem: I can't import components or variables directly inside of MDX files when I use next-mdx-remote to compile them. In this link you can read more about this.
Solution: I had to pass a config object when I invoked the function. The property is an object that contains the data (variables) that I need to use inside of my MDX files.
Finally, this is the component that renders a blog post:
blog-post-page.tsx
"use client";importdynamicfrom"next/dynamic";import{withRenderInBrowser}from"@diegofrayo-pkg/hocs";importtypeUtilsTypesfrom"@diegofrayo-pkg/types";importtypeReactTypesfrom"@diegofrayo-pkg/types/react";import{toQueryString}from"@diegofrayo-pkg/utilities/navigation";import{generateSlug}from"@diegofrayo-pkg/utilities/strings";import{isEmptyArray}from"@diegofrayo-pkg/validator";import{BoxWithTitle,Callout,CopyToClipboardPopover,ImageWithLink,Toast,Tooltip,}from"~/components/common";importtype{CopyToClipboardPopoverProps}from"~/components/common/copy-to-clipboard-popover";import{MainLayout}from"~/components/layout";import{Box,Button,Icon,Image,InlineText,Link,List,Paragraph,Separator,}from"~/components/primitive";import{IconCatalog}from"~/components/primitive/icon";import{LinkVariants}from"~/components/primitive/link";import{ListVariants}from"~/components/primitive/list";import{BLOG_IMAGES_PATH}from"~/constants";importWEBSITE_METADATAfrom"~/data/metadata.json";importAnalyticsServicefrom"~/features/analytics";import{getMDXExport,MDXContent}from"~/features/mdx/client";import{BlogPostCategory}from"../../components/blog-post-category";importtype{BlogPost,BlogPostWithContent}from"../../types";exporttypeBlogPostPageProps={data:BlogPostWithContent;};functionBlogPostPage({data}:BlogPostPageProps):ReactTypes.JSXElement{const{details:blogPostDetails,content:blogPostContent}=data;return(<MainLayouttitle={blogPostDetails.title}><BoxclassName="border-t border-zinc-100 bg-slate-50 shadow-sm shadow-zinc-300"><Imagesrc={`${BLOG_IMAGES_PATH}/thumbnails/${blogPostDetails.slug}.png`}alt={blogPostDetails.title}className="mx-auto h-auto w-full max-w-full"width={600}height={338}loading="eager"/></Box><Separatorsize={1.5}/><BlogPostDetailsdetails={blogPostDetails}/><Separatorsize={1.5}/><MDXContentcode={blogPostContent}components={getBlogPostDynamicComponents(getMDXExport(blogPostContent))}globals={{DATA:{post:blogPostDetails},}}/><Separatorsize={4}/><BlogPostSourcessources={blogPostDetails.sources}/><Separatorsize={2}/><BlogPostActionsblogPostTitle={blogPostDetails.title}/></MainLayout>);}exportdefaultBlogPostPage;// --- UTILS ---functiongetBlogPostDynamicComponents(componentsMap:UtilsTypes.Object<string>,): UtilsTypes.Object<unknown>{constDYNAMIC_COMPONENTS=["Playground","MFMAMGitHubRepo","MFMAMHelloWorldMDX","MFMAMSpotifyPlaylist","SPVEEQPlaces","SPVEEQRecommendations",];constcomponents={Callout,CopyToClipboardPopover,ImageWithLink,Toast,Tooltip,} as UtilsTypes.Object; Object.keys(componentsMap["Components"] || {}).forEach((componentName) =>{if(DYNAMIC_COMPONENTS.includes(componentName)){switch(componentName){case"Playground":{components[componentName]=dynamic(()=>import("../../../../../components/common/playground"),{ssr:true}, ); break;} case "MFMAMGitHubRepo": {components[componentName]=dynamic(()=>import("./components/my-favorite-music-and-mdx/mfmam-github-repo"),{ssr:true}, ); break;} case "MFMAMHelloWorldMDX": {components[componentName]=dynamic(()=>import("./components/my-favorite-music-and-mdx/mfmam-hello-world-mdx"),{ssr:true}, ); break;} case "MFMAMSpotifyPlaylist": {components[componentName]=dynamic(()=>import("./components/my-favorite-music-and-mdx/mfmam-spotify-playlist"),{ssr:true}, ); break;} case "SPVEEQPlaces": {components[componentName]=dynamic(()=>import("./components/sitios-para-visitar-en-el-quindio/spveeq-places"),{ssr:true}, ); break;} case "SPVEEQRecommendations": {components[componentName]=dynamic(()=>import("./components/sitios-para-visitar-en-el-quindio/spveeq-recommendations"),{ssr:true}, ); break;} default: break;}}}); return components;}// --- COMPONENTS ---function BlogPostDetails({details,}: {details:BlogPostPageProps["data"]["details"];}): ReactTypes.JSXElement {return(<BoxclassName="border border-zinc-200 bg-zinc-50 py-4 text-center text-sm"><Paragraph><Iconname={IconCatalog.CALENDAR}className="relative -top-px"/>{""}<InlineText>Published at</InlineText><InlineTextas="strong">{details.published_at}</InlineText></Paragraph><Separatorsize={0.5}/><BoxclassName="flex flex-wrap items-center justify-center gap-x-2">{details.categories.map((category)=>{return(<BlogPostCategorykey={category}text={category}/> );})}</Box></Box>);}functionBlogPostSources({sources}:Pick<BlogPost,"sources">): ReactTypes.JSXElementNullable {if(isEmptyArray(sources))returnnull;return(<BoxWithTitletitle="Sources"className="border-zinc-300 p-4 pb-2"><Listvariant={ListVariants.SIMPLE}>{sources.map((source)=>{const{host} = new URL(source.url); return (<List.Itemkey={generateSlug(source.title)}><Linkvariant={LinkVariants.STYLED}href={source.url}isExternalLink>{source.title}</Link><ParagraphclassName="text-xs italic">{host}</Paragraph></List.Item>);})}</List></BoxWithTitle>);}constBlogPostActions=withRenderInBrowser(functionBlogPostActions({blogPostTitle,}:{blogPostTitle:string;}){constACTIONS=[{type:"LINK",icon:IconCatalog.MAILS,label:"Send a comment via e-mail",popoverConfig:undefined,props:{href:(functioncomposeMailToURL():string{constparamsValues={subject:"Blog post comment",body:`Hi, I have a comment about this blog post: ${window.location.href}`,};constqueryParams=toQueryString(paramsValues);constresult=`mailto:${WEBSITE_METADATA.email}?${queryParams}`;returnresult;})(),isExternalLink:true,onClick:AnalyticsService.trackClickEvent("BLOG|SEND_EMAIL",{post:blogPostTitle}),},},{type:"BUTTON",icon:IconCatalog.LINK,label:"Copy URL",popoverConfig:{textToCopy:window.location.href},props:{onClick:AnalyticsService.trackClickEvent("BLOG|COPY_URL",{post:blogPostTitle}),},},]asconst;return(<BoxclassName="flex flex-col flex-wrap items-start justify-between gap-2 border border-x-8 border-black p-4 text-black sm:flex-row">{ACTIONS.map((action)=>{return(<BlogPostActionsItemWrapperkey={action.label}popoverConfig={action.popoverConfig}><ButtonclassName="inline-flex items-center justify-start text-left text-sm"render={action.type==="LINK"?<Link{...action.props}/> : undefined} onClick={action.type==="BUTTON"?action.props.onClick:undefined}><IconclassName="mr-1"name={action.icon}/><InlineText>{action.label}</InlineText></Button></BlogPostActionsItemWrapper>);})}</Box>);});typeBlogPostActionsItemWrapperProps={children:ReactTypes.Children;popoverConfig:Omit<CopyToClipboardPopoverProps,"children"> | undefined;};const BlogPostActionsItemWrapper = ({children,popoverConfig,}: BlogPostActionsItemWrapperProps): ReactTypes.Node =>{if(popoverConfig){return(<CopyToClipboardPopoverpopoverInnerWrapperClassName="leading-none"{...popoverConfig}>{children}</CopyToClipboardPopover>);}returnchildren;};
In regards to the MDX components styling, at the beginning I used this Tailwind CSS plugin, but then, I decided don't use it anymore and I built a component with my own styles.
mdx-config.tsx
import{getMDXComponent,getMDXExportasgetMDXExportBase}from"mdx-bundler/client";import{useMemo}from"react";importcnfrom"@diegofrayo-pkg/cn";importtypeUtilsTypesfrom"@diegofrayo-pkg/types";importtypeReactTypesfrom"@diegofrayo-pkg/types/react";import{isString}from"@diegofrayo-pkg/validator";import{BoxWithTitle,CopyToClipboardPopover,ImageWithLink,SourceCode}from"~/components/common";import{Blockquote,Box,Button,Code,Collapsible,Icon,InlineText,Link,List,Paragraph,Separator,Title,}from"~/components/primitive";import{ButtonVariants}from"~/components/primitive/button";import{CodeVariants}from"~/components/primitive/code";import{typeImageProps}from"~/components/primitive/image";import{LinkVariants}from"~/components/primitive/link";import{ListVariants}from"~/components/primitive/list";import{SeparatorVariants}from"~/components/primitive/separator";import{TitleVariants,typeTitleProps}from"~/components/primitive/title";importstylesfrom"./styles.module.css";typeMDXContentProps={code:string;components?:UtilsTypes.Object;globals?:UtilsTypes.Object;};exportfunctionMDXContent({code,components={},globals={},}:MDXContentProps):ReactTypes.JSXElement{constComponent=useMemo(()=>getMDXComponent(code,globals),[code,globals]);return(<BoxclassName={cn("dr-mdx-content",styles["dr-mdx-content"])}>{/* eslint-disable-next-line react-hooks/static-components */}<Componentcomponents={{...MDXComponents,...components}}/></Box>);}// NOTE: mdx-bundler ships broken types for this function (`ExportedObject` is undeclared), so the return type is set hereexportfunctiongetMDXExport(code:string):UtilsTypes.Object<string>{returngetMDXExportBase(code)asUtilsTypes.Object<string>;}// --- CONSTANTS ---// DOCS: https://mdxjs.com/table-of-componentsconst MDXComponents = {// --- PRIMITIVE COMPONENTS ---a:functionCustomA({children,href,}: ReactTypes.DOM.HTMLElementAttributes["a"]): ReactTypes.JSXElement {return(<Linkhref={href||""}variant={LinkVariants.STYLED}isExternalLink>{children}</Link>);},blockquote:Blockquote,code:functionCustomCode({children,}:ReactTypes.DOM.HTMLElementAttributes["code"]):ReactTypes.JSXElement{if(isString(children)){return(<CopyToClipboardPopovertextToCopy={children}><Buttonvariant={ButtonVariants.SMOOTH}><Codevariant={CodeVariants.STYLED}>{children}</Code></Button></CopyToClipboardPopover>);}thrownewError("Code component: Invalid children");},h1:createTitleComponent("h1",{className:"text-3xl md:text-5xl"}),h2:createTitleComponent("h2",{className:"text-2xl md:text-4xl"}),h3:createTitleComponent("h3",{className:"text-xl md:text-3xl"}),h4:createTitleComponent("h4",{className:"text-lg md:text-2xl"}),hr:functionCustomHr():ReactTypes.JSXElement{return<Separatorvariant={SeparatorVariants.DASHED}/>;}, li: List.Item, p: Paragraph, pre: function CustomPre({children,}: ReactTypes.DOM.HTMLElementAttributes["pre"]): ReactTypes.JSXElement {return<SourceCodecode={children}/>;}, ul: function CustomUl({children,}: ReactTypes.DOM.HTMLElementAttributes["ul"]): ReactTypes.JSXElement {return<Listvariant={ListVariants.SIMPLE}>{children}</List>;},// --- CUSTOM COMPONENTS ---Box,BoxWithTitle,Collapsible,Icon,InlineText,Link,MDXImage:(props:ImageProps):ReactTypes.JSXElement=>{return(<ImageWithLinkclassName="shadow-md shadow-zinc-900"{...props}/> );}, SourceCode, Separator,};// --- UTILS ---function createTitleComponent(Tag: "h1" | "h2" | "h3" | "h4", props: Omit<TitleProps,"as">) {returnfunctionCustomTitle({children,}: ReactTypes.DOM.HTMLElementAttributes["h1"]): ReactTypes.JSXElement {return(<Title{...props}as={Tag}variant={TitleVariants.STYLED}>{children}</Title>);};}
I share the URL of this repository in case of you want to see more details about my website and my blog section were implemented. Allright, this is all, thanks for reading.