frontendcorner logo
  • Aug 11, 2023

  • 7 min read

Contentlayer and Next.js - how easy and powerful is it?

Contentlayer and Next.js - how easy and powerful is it?

When I was sitting out to create my own blog, I spent quite a long time thinking - how to handle and where to store my posts.

Should I use headless CMS like Contentful or maybe the better option would be to managing mdx files and parsing them using, for example, remark? The second option seemed very good for me, also because I saw an entire section dedicated to this on Next.js documentation, so the setup should be very easy.

However, once upon a time, when I was browsing my Twitter bubble, I came across a tweet from @delba_oliveira where she described the new Next.js documentation stack.

As you can see, MDX files are processed by Contentlayer. After reviewing the documentation, I decided to try it out on my own blog. Now, I can truly say that it was a great choice.

Handling MDX files, generating types, transforming data, and integrating easily with the Next.js App Directory - all these aspects contribute to a dev experience so outstanding that I simply had to share it with you.

Ok, but let's take it step by step!

Contentlayer setup

If you have existing project already, the setup will be very fast. Just install these two packages by running this command:

npm install contentlayer next-contentlayer
# or
yarn add contentlayer next-contentlayer
# or
pnpm add contentlayer next-contentlayer

The next step is to update the next.config.js file. The only thing you will have to do is to wrap the export with withContentLayer function like below:

// next.config.js
const { withContentlayer } = require('next-contentlayer');

/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true };

module.exports = withContentlayer(nextConfig);

And the last thing. Contentlayer generates a .contentlayer directory during the build time, where are stored the json's of .mdx files (we will cover that later).

To tell the editor where it should look for the files, we have to add two lines to our tsconfig.json.

// tsconfig.json
{
  "compilerOptions": {
    //...
    "paths": {
      "@components/*": ["./components/*"],
      "contentlayer/generated": ["./.contentlayer/generated"] // <---
    }
  },
  "include": [
    //...
    ".contentlayer/generated" // <---
  ],
  "exclude": ["node_modules"]
}

When it comes to the setup itself, that's all. It was not too much work, I suppose. Let's move on to next part and check out how the Contentlayer handles the schema.

Schema definition

And here we come to the point where I am was very impressed about. As you probably know, each md/mdx file, apart from the body itself, may have a metadata information at the beginning of the file. Something like that:

---
title: 'Hello there!'
description: 'I am glad you are here!'
publishedAt: '2023-03-22'
---

Content of the markdown file...

When I was thinking about my blog, I wanted to have fully type-safe content and thanks to Contentlayer, I achieve it soo nicely and quickly.

First thing I had to do, was defining schema of each post. To do that, I have to create a contentlayer.config.ts file in the root of the project and place schema of the Post there.

// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files';

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `*.mdx`,
  contentType: 'mdx', // Make sure you have mdx here
  fields: {
    title: { type: 'string', required: true },
    publishedAt: { type: 'date', required: true },
    description: { type: 'string', required: true },
    readIn: { type: 'string', required: true },
    image: { type: 'string' },
  },
}));

export default makeSource({
  contentDirPath: './content',
  documentTypes: [Post],
});

In my case, each of posts has metadata information about the title, description etc. which are strings.

At the bottom of the file, you can see invocation of makeSource function, which provide content and schema for our app. There is contentDirPath option which points to the folder where our posts are stored (in my case it's content directory).

And now, the magic happens. After running the app, Contentlayer generate .contentlayer directory during the build time, with all of our posts (converted to JSON files), types etc.

// .contentlayer/generated/Post/sample-post.mdx.json
{
  "title": "Hello there!",
  "publishedAt": "2023-03-22T00:00:00.000Z",
  "description": "I am glad you are here!",
  "body": {
    "raw": "\nContent of the markdown file...",
    "code": "var Component=(()=>{var sr=Object.create;var F=Object.defineProperty;var lr=Object.getOwnPropertyDescriptor;var fr=Object.getOwnPropertyNames;var cr=Object.getPrototypeOf,dr=Object.prototype.hasOwnProperty;var q=(s,l)=>()=>(l||s((l={exports:{}}).exports,l),l.exports),vr=(s,l)=>{for(var b in l)F(s,b,{get:l[b],enumerable:!0})},_e=(s,l,b,m)=>{if(l&&typeof l==\"object\"||typeof l==\"function\")for(let E of fr(l))!dr.call(s,E)&&E!==b&&F(s,E,{get:()=>l[E],enumerable:!(m=lr(l,E))||m.enumerable});return s};var pr=(s,l,b)=>(b=s!=null?sr(cr(s)):{},_e(l||!s||!s.__esModule?F(b,\"default\",{value:s,enumerable:!0}):b,s)),br=s=>_e(F({},\"__esModule\",{value:!0}),s);var Te=q((_r,Re)=>{Re.exports=React});var Ce=q(z=>{\"use strict\";(function(){\"use strict\";var s=Te(),l=Symbol.for(\"react.element\"),b=Symbol.for(\"react.portal\"),m=Symbol.for(\"react.fragment\"),E=Symbol.for(\"react.strict_mode\"),X=Symbol.for(\"react.profiler\"),H=Symbol.for(\"react.provider\"),K=Symbol.for(\"react.context\"),O=Symbol.for(\"react.forward_ref\"),I=Symbol.for(\"react.suspense\"),Y=Symbol.for(\"react.suspense_list\"),w=Symbol.for(\"react.memo\"),$=Symbol.for(\"react.lazy\"),Se=Symbol.for(\"react.offscreen\"),J=Symbol.iterator,je=\"@@iterator\";function xe(e){if(e===null||typeof e!=\"object\")return null;var r=J&&e[J]||e[je];return typeof r==\"function\"?r:null}var _=s.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;function d(e){{for(var r=arguments.length,t=new Array(r>1?r-1:0),n=1;n<r;n++)t[n-1]=arguments[n];ke(\"error\",e,t)}}function ke(e,r,t){{var n=_.ReactDebugCurrentFrame,i=n.getStackAddendum();i!==\"\"&&(r+=\"%s\",t=t.concat([i]));var u=t.map(function(o){return String(o)});u.unshift(\"Warning: \"+r),Function.prototype.apply.call(console[e],console,u)}}var De=!1,Fe=!1,Ae=!1,Ie=!1,Ye=!1,Z;Z=Symbol.for(\"react.module.reference\");function $e(e){return!!(typeof e==\"string\"||typeof e==\"function\"||e===m||e===X||Ye||e===E||e===I||e===Y||Ie||e===Se||De||Fe||Ae||typeof e==\"object\"&&e!==null&&(e.$$typeof===$||e.$$typeof===w||e.$$typeof===H||e.$$typeof===K||e.$$typeof===O||e.$$typeof===Z||e.getModuleId!==void 0))}function We(e,r,t){var n=e.displayName;if(n)return n;var i=r.displayName||r.name||\"\";return i!==\"\"?t+\"(\"+i+\")\":t}function Q(e){return e.displayName||\"Context\"}function g(e){if(e==null)return null;if(typeof e.tag==\"number\"&&d(\"Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue.\"),typeof e==\"function\")return e.displayName||e.name||null;if(typeof e==\"string\")return e;switch(e){case m:return\"Fragment\";case b:return\"Portal\";case X:return\"Profiler\";case E:return\"StrictMode\";case I:return\"Suspense\";case Y:return\"SuspenseList\"}if(typeof e==\"object\")switch(e.$$typeof){case K:var r=e;return Q(r)+\".Consumer\";case H:var t=e;return Q(t._context)+\".Provider\";case O:return We(e,e.render,\"ForwardRef\");case w:var n=e.displayName||null;return n!==null?n:g(e.type)||\"Memo\";case $:{var i=e,u=i._payload,o=i._init;try{return g(o(u))}catch{return null}}}return null}var y=Object.assign,C=0,ee,re,te,ne,ae,oe,ie;function ue(){}ue.__reactDisabledLog=!0;function Ne(){{if(C===0){ee=console.log,re=console.info,te=console.warn,ne=console.error,ae=console.group,oe=console.groupCollapsed,ie=console.groupEnd;var e={configurable:!0,enumerable:!0,value:ue,writable:!0};Object.defineProperties(console,{info:e,log:e,warn:e,error:e,group:e,groupCollapsed:e,groupEnd:e})}C++}}function Me(){{if(C--,C===0){var e={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:y({},e,{value:ee}),info:y({},e,{value:re}),warn:y({},e,{value:te}),error:y({},e,{value:ne}),group:y({},e,{value:ae}),groupCollapsed:y({},e,{value:oe}),groupEnd:y({},e,{value:ie})})}C<0&&d(\"disabledDepth fell below zero. This is a bug in React. Please file an issue.\")}}var W=_.ReactCurrentDispatcher,N;function S(e,r,t){{if(N===void 0)try{throw Error()}catch(i){var n=i.stack.trim().match(/\\n( *(at )?)/);N=n&&n[1]||\"\"}return`\n`+N+e}}var M=!1,j;{var Ve=typeof WeakMap==\"function\"?WeakMap:Map;j=new Ve}function se(e,r){if(!e||M)return\"\";{var t=j.get(e);if(t!==void 0)return t}var n;M=!0;var i=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var u;u=W.current,W.current=null,Ne();try{if(r){var o=function(){throw Error()};if(Object.defineProperty(o.prototype,\"props\",{set:function(){throw Error()}}),typeof Reflect==\"object\"&&Reflect.construct){try{Reflect.construct(o,[])}catch(h){n=h}Reflect.construct(e,[],o)}else{try{o.call()}catch(h){n=h}e.call(o.prototype)}}else{try{throw Error()}catch(h){n=h}e()}}catch(h){if(h&&n&&typeof h.stack==\"string\"){for(var a=h.stack.split(`\n`),v=n.stack.split(`\n`),f=a.length-1,c=v.length-1;f>=1&&c>=0&&a[f]!==v[c];)c--;for(;f>=1&&c>=0;f--,c--)if(a[f]!==v[c]){if(f!==1||c!==1)do if(f--,c--,c<0||a[f]!==v[c]){var p=`\n`+a[f].replace(\" at new \",\" at \");return e.displayName&&p.includes(\"<anonymous>\")&&(p=p.replace(\"<anonymous>\",e.displayName)),typeof e==\"function\"&&j.set(e,p),p}while(f>=1&&c>=0);break}}}finally{M=!1,W.current=u,Me(),Error.prepareStackTrace=i}var T=e?e.displayName||e.name:\"\",ye=T?S(T):\"\";return typeof e==\"function\"&&j.set(e,ye),ye}function Ue(e,r,t){return se(e,!1)}function Le(e){var r=e.prototype;return!!(r&&r.isReactComponent)}function x(e,r,t){if(e==null)return\"\";if(typeof e==\"function\")return se(e,Le(e));if(typeof e==\"string\")return S(e);switch(e){case I:return S(\"Suspense\");case Y:return S(\"SuspenseList\")}if(typeof e==\"object\")switch(e.$$typeof){case O:return Ue(e.render);case w:return x(e.type,r,t);case $:{var n=e,i=n._payload,u=n._init;try{return x(u(i),r,t)}catch{}}}return\"\"}var k=Object.prototype.hasOwnProperty,le={},fe=_.ReactDebugCurrentFrame;function D(e){if(e){var r=e._owner,t=x(e.type,e._source,r?r.type:null);fe.setExtraStackFrame(t)}else fe.setExtraStackFrame(null)}function Be(e,r,t,n,i){{var u=Function.call.bind(k);for(var o in e)if(u(e,o)){var a=void 0;try{if(typeof e[o]!=\"function\"){var v=Error((n||\"React class\")+\": \"+t+\" type `\"+o+\"` is invalid; it must be a function, usually from the `prop-types` package, but received `\"+typeof e[o]+\"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.\");throw v.name=\"Invariant Violation\",v}a=e[o](r,o,n,t,null,\"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED\")}catch(f){a=f}a&&!(a instanceof Error)&&(D(i),d(\"%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).\",n||\"React class\",t,o,typeof a),D(null)),a instanceof Error&&!(a.message in le)&&(le[a.message]=!0,D(i),d(\"Failed %s type: %s\",t,a.message),D(null))}}}var Ge=Array.isArray;function V(e){return Ge(e)}function qe(e){{var r=typeof Symbol==\"function\"&&Symbol.toStringTag,t=r&&e[Symbol.toStringTag]||e.constructor.name||\"Object\";return t}}function ze(e){try{return ce(e),!1}catch{return!0}}function ce(e){return\"\"+e}function de(e){if(ze(e))return d(\"The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.\",qe(e)),ce(e)}var P=_.ReactCurrentOwner,Xe={key:!0,ref:!0,__self:!0,__source:!0},ve,pe,U;U={};function He(e){if(k.call(e,\"ref\")){var r=Object.getOwnPropertyDescriptor(e,\"ref\").get;if(r&&r.isReactWarning)return!1}return e.ref!==void 0}function Ke(e){if(k.call(e,\"key\")){var r=Object.getOwnPropertyDescriptor(e,\"key\").get;if(r&&r.isReactWarning)return!1}return e.key!==void 0}function Je(e,r){if(typeof e.ref==\"string\"&&P.current&&r&&P.current.stateNode!==r){var t=g(P.current.type);U[t]||(d('Component \"%s\" contains the string ref \"%s\". Support for string refs will be removed in a future major release. This case cannot be automatically converted to an arrow function. We ask you to manually fix this case by using useRef() or createRef() instead. Learn more about using refs safely here: https://reactjs.org/link/strict-mode-string-ref',g(P.current.type),e.ref),U[t]=!0)}}function Ze(e,r){{var t=function(){ve||(ve=!0,d(\"%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)\",r))};t.isReactWarning=!0,Object.defineProperty(e,\"key\",{get:t,configurable:!0})}}function Qe(e,r){{var t=function(){pe||(pe=!0,d(\"%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)\",r))};t.isReactWarning=!0,Object.defineProperty(e,\"ref\",{get:t,configurable:!0})}}var er=function(e,r,t,n,i,u,o){var a={$$typeof:l,type:e,key:r,ref:t,props:o,_owner:u};return a._store={},Object.defineProperty(a._store,\"validated\",{configurable:!1,enumerable:!1,writable:!0,value:!1}),Object.defineProperty(a,\"_self\",{configurable:!1,enumerable:!1,writable:!1,value:n}),Object.defineProperty(a,\"_source\",{configurable:!1,enumerable:!1,writable:!1,value:i}),Object.freeze&&(Object.freeze(a.props),Object.freeze(a)),a};function rr(e,r,t,n,i){{var u,o={},a=null,v=null;t!==void 0&&(de(t),a=\"\"+t),Ke(r)&&(de(r.key),a=\"\"+r.key),He(r)&&(v=r.ref,Je(r,i));for(u in r)k.call(r,u)&&!Xe.hasOwnProperty(u)&&(o[u]=r[u]);if(e&&e.defaultProps){var f=e.defaultProps;for(u in f)o[u]===void 0&&(o[u]=f[u])}if(a||v){var c=typeof e==\"function\"?e.displayName||e.name||\"Unknown\":e;a&&Ze(o,c),v&&Qe(o,c)}return er(e,a,v,i,n,P.current,o)}}var L=_.ReactCurrentOwner,be=_.ReactDebugCurrentFrame;function R(e){if(e){var r=e._owner,t=x(e.type,e._source,r?r.type:null);be.setExtraStackFrame(t)}else be.setExtraStackFrame(null)}var B;B=!1;function G(e){return typeof e==\"object\"&&e!==null&&e.$$typeof===l}function ge(){{if(L.current){var e=g(L.current.type);if(e)return`\n\nCheck the render method of \\``+e+\"`.\"}return\"\"}}function tr(e){{if(e!==void 0){var r=e.fileName.replace(/^.*[\\\\\\/]/,\"\"),t=e.lineNumber;return`\n\nCheck your code at `+r+\":\"+t+\".\"}return\"\"}}var he={};function nr(e){{var r=ge();if(!r){var t=typeof e==\"string\"?e:e.displayName||e.name;t&&(r=`\n\nCheck the top-level render call using <`+t+\">.\")}return r}}function Ee(e,r){{if(!e._store||e._store.validated||e.key!=null)return;e._store.validated=!0;var t=nr(r);if(he[t])return;he[t]=!0;var n=\"\";e&&e._owner&&e._owner!==L.current&&(n=\" It was passed a child from \"+g(e._owner.type)+\".\"),R(e),d('Each child in a list should have a unique \"key\" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',t,n),R(null)}}function me(e,r){{if(typeof e!=\"object\")return;if(V(e))for(var t=0;t<e.length;t++){var n=e[t];G(n)&&Ee(n,r)}else if(G(e))e._store&&(e._store.validated=!0);else if(e){var i=xe(e);if(typeof i==\"function\"&&i!==e.entries)for(var u=i.call(e),o;!(o=u.next()).done;)G(o.value)&&Ee(o.value,r)}}}function ar(e){{var r=e.type;if(r==null||typeof r==\"string\")return;var t;if(typeof r==\"function\")t=r.propTypes;else if(typeof r==\"object\"&&(r.$$typeof===O||r.$$typeof===w))t=r.propTypes;else return;if(t){var n=g(r);Be(t,e.props,\"prop\",n,e)}else if(r.PropTypes!==void 0&&!B){B=!0;var i=g(r);d(\"Component %s declared `PropTypes` instead of `propTypes`. Did you misspell the property assignment?\",i||\"Unknown\")}typeof r.getDefaultProps==\"function\"&&!r.getDefaultProps.isReactClassApproved&&d(\"getDefaultProps is only used on classic React.createClass definitions. Use a static property named `defaultProps` instead.\")}}function or(e){{for(var r=Object.keys(e.props),t=0;t<r.length;t++){var n=r[t];if(n!==\"children\"&&n!==\"key\"){R(e),d(\"Invalid prop `%s` supplied to `React.Fragment`. React.Fragment can only have `key` and `children` props.\",n),R(null);break}}e.ref!==null&&(R(e),d(\"Invalid attribute `ref` supplied to `React.Fragment`.\"),R(null))}}function ir(e,r,t,n,i,u){{var o=$e(e);if(!o){var a=\"\";(e===void 0||typeof e==\"object\"&&e!==null&&Object.keys(e).length===0)&&(a+=\" You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.\");var v=tr(i);v?a+=v:a+=ge();var f;e===null?f=\"null\":V(e)?f=\"array\":e!==void 0&&e.$$typeof===l?(f=\"<\"+(g(e.type)||\"Unknown\")+\" />\",a=\" Did you accidentally export a JSX literal instead of a component?\"):f=typeof e,d(\"React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s\",f,a)}var c=rr(e,r,t,i,u);if(c==null)return c;if(o){var p=r.children;if(p!==void 0)if(n)if(V(p)){for(var T=0;T<p.length;T++)me(p[T],e);Object.freeze&&Object.freeze(p)}else d(\"React.jsx: Static children should always be an array. You are likely explicitly calling React.jsxs or React.jsxDEV. Use the Babel transform instead.\");else me(p,e)}return e===m?or(c):ar(c),c}}var ur=ir;z.Fragment=m,z.jsxDEV=ur})()});var Oe=q((Tr,Pe)=>{\"use strict\";Pe.exports=Ce()});var mr={};vr(mr,{default:()=>Er,frontmatter:()=>gr});var A=pr(Oe()),gr={title:\"Hello there!\",publishedAt:\"2023-03-22\",subtitle:\"I am glad you are here!\"};function we(s){let l=Object.assign({p:\"p\"},s.components);return(0,A.jsxDEV)(l.p,{children:\"Content of the markdown file...\"},void 0,!1,{fileName:\"/Users/david/Projekty/next-13-blog/content/_mdx_bundler_entry_point-f61b475a-76dd-4b7d-bb67-18184c3976a2.mdx\",lineNumber:7,columnNumber:1},this)}function hr(s={}){let{wrapper:l}=s.components||{};return l?(0,A.jsxDEV)(l,Object.assign({},s,{children:(0,A.jsxDEV)(we,s,void 0,!1,{fileName:\"/Users/david/Projekty/next-13-blog/content/_mdx_bundler_entry_point-f61b475a-76dd-4b7d-bb67-18184c3976a2.mdx\"},this)}),void 0,!1,{fileName:\"/Users/david/Projekty/next-13-blog/content/_mdx_bundler_entry_point-f61b475a-76dd-4b7d-bb67-18184c3976a2.mdx\"},this):we(s)}var Er=hr;return br(mr);})();\n/*! Bundled license information:\n\nreact/cjs/react-jsx-dev-runtime.development.js:\n  (**\n   * @license React\n   * react-jsx-dev-runtime.development.js\n   *\n   * Copyright (c) Facebook, Inc. and its affiliates.\n   *\n   * This source code is licensed under the MIT license found in the\n   * LICENSE file in the root directory of this source tree.\n   *)\n*/\n;return Component;"
  },
  "_id": "sample-post.mdx",
  "_raw": {
    "sourceFilePath": "sample-post.mdx",
    "sourceFileName": "sample-post.mdx",
    "sourceFileDir": ".",
    "contentType": "mdx",
    "flattenedPath": "sample-post"
  },
  "type": "Post"
}

As you can see, Contentlayer converted my example post to the JSON file with the same properties we defined in .mdx file, and added few additional like _raw or _id.

I also wanted to add a unique field for each post that I could use in the URL. There was an option to add additional field to each post, for example, "slug", or use what I already have, which is name file name, right? (which I already have in _raw.flattenedPath).

And here, the Contentlayer gave me a hand again. So, instead of reading field directly from the mdx file, I could create a field on the fly using computedFields, which is awesome!

// contentlayer.config.ts

// previous code ...

export const Post = defineDocumentType(() => ({
  // previous code ...
  computedFields: {
    slug: {
      type: 'string',
      resolve: (post) => post._raw.flattenedPath,
    }
    // additional fields
  },
}));

// makeSource...

Cool, right? And you can add whatever you want. Ok, we have our schema, but do we render our post?

Post rendering

Of course, there is simple solution - take the raw body and place it to dangerouslyInnerHtml, for example, or make it even easier and use the special hook that Contentlayer has prepared for us.

The only thing you have to do is pass body.code to the hook. But how do you get the post you want? If you look at .contentlayer directory you will find that there is an index.mjs file which has exported all of our posts.

So, to get the post you want, just create helper function that will filter out and return the proper post. Something like that:

import { allPosts, Post } from 'contentlayer/generated'; // That's why we needed tsconfig changes

export default async function Page({ params }: PostProps) {
  const post = getPost(params.slug);
  if (!post) {
    notFound();
  }

  return (
    <div className="relative">
      <Mdx className="px-4 max-w-3xl mx-auto" content={post.body.code} />
      {/*rest...*/}
    </div>
  );
}
export function getPost(slug: string): Post | undefined {
  return allPosts.find((post) => post.slug === slug);
}

And then, inside Mdx component, use the hook and render content out:

export const Mdx: React.FC<MdxProps> = ({ content }) => {
  const Content = useMDXComponent(content);
  return (
    <article className='prose prose-neutral dark:prose-invert'>
      <Content  />
    </article>
  );
};

And that's it. I don't know what you think, but the first time, I was very impressed.

Customizing mdx content

However, what if we would like to customize our h2 element and show anchor after hovering? (try it out on the heading above) Nice, isn't it?

Well, to achieve this, we can use our hook again, but additionally, we have to pass the prop with custom components:

const components = {
  h2: MdxH2,
};

export const Mdx: React.FC<MdxProps> = ({ content }) => {
  const Content = useMDXComponent(content);
  return (
    <article className='prose prose-neutral dark:prose-invert'>
      <Content components={components} />
    </article>
  );
};

function MdxH2({ children, ...props }: ComponentProps<'h2'>) {
  const anchor = getAnchor(children as string);
  return (
    <h2 {...props} id={anchor} className="relative group">
      <a
        href={`#${anchor}`}
        className="w-full h-full flex items-center absolute -left-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 md:-left-5"
      >
        <LinkSvg className="w-4 h-4" />
      </a>

      {children}
    </h2>
  );
}

function getAnchor(text: string) {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9 ]/g, '')
    .replace(/[ ]/g, '-');
}

Conclusion

So, as you can see, the setup itself is quite easy, as are the possibilities that Contentlayer gives us. If you are curious how the results looks like, you are looking at it right now - and as you can tell, it works, and works excellently!

Of course, that was just a basic setup - you can do much, much more. If you are as impressed as I am, let me know by hitting me up on Twitter!