How to create renderer plugin from scratch

How to create renderer plugin from scratch

This guide walks you through creating a custom JSX renderer plugin. We’ll build it step by step, starting with the basics.

Basic Structure

First, we need to create our renderer class that extends the base plugin and then we define the template for the renderer:

index.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { BaseRendererPlugin } from 'notion-to-md';

export class JSXRenderer extends BaseRendererPlugin {
  protected template = `{{{imports}}}

export function NotionContent({ components = {} }) {
  return (
    <div className="notion-content">
      {{{content}}}
    </div>
  );
}`;

}

Let’s understand what’s happening:

  • We extend BaseRendererPlugin to get core rendering capabilities
  • Our template defines the output structure with two variables:
    • imports: For component imports
    • content: For the actual content
  • The template creates a React component that wraps our content

Adding config

To allow customization, we’ll introduce a configuration option in the plugin’s metadata. In this example, users can specify a custom name for the component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { BaseRendererPlugin } from 'notion-to-md';

export interface JSXRendererConfig {
  componentName?: string;
}

export class JSXRenderer extends BaseRendererPlugin {
  protected template = `{{{imports}}}

export function {{{component}}}({ components = {} }) {
  return (
    <div className="notion-content">
      {{{content}}}
    </div>
  );
}`;

  constructor(config: JSXRendererConfig = {}) {
    super();
    this.addMetadata('config', config); // Add config to metadata against key 'config'
  }
}

Note that we’ve replaced NotionContent with the {{{component}}} variable (name it whatever you want). We’ll be dynamically substituting with the component name specified in the user’s configuration.

Adding Annotation Transformers

Annotation transformers handle inline text formatting. Each transformer converts Notion’s text formatting to JSX:

transformers/annotations.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const annotationTransformers = {
  bold: {
    transform: async ({ text }) => `<strong>${text}</strong>`
  },

  italic: {
    transform: async ({ text }) => `<em>${text}</em>`
  },

  link: {
    transform: async ({ text, link }) => {
      if (!link?.url) return text;
      return `<a href="${link.url}" target="_blank" rel="noopener">${text}</a>`;
    }
  },

  code: {
    transform: async ({ text }) => `<code className="inline-code">${text}</code>`
  }
  // ...other annotations
};

Tip

Read more about annotation transformers

Let’s add these to our renderer:

index.ts
45
46
47
48
49
50
// Update constructor
constructor(config: JSXRendererConfig = {}) {
  super();
  this.addMetadata('config', config);
  this.createAnnotationTransformers(annotationTransformers);
}

Adding Block Transformers

Let’s add more essential block transformers and integrate them into our renderer:

transformers/blocks.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const blockTransformers = {
  paragraph: {
    transform: async ({ block, utils }) => {
      const text = await utils.processRichText(block.paragraph.rich_text);
      return `<p className="notion-paragraph">${text}</p>\n\n`;
    }
  },

  // heading with support for toggle
  heading_1: {
    transform: async ({ block, utils }) => {
      const headingBlock = block.heading_1;
      const isToggle = headingBlock.is_toggleable;
      const text = await utils.processRichText(headingBlock.rich_text);

      if (!isToggle) {
        return `<h1 className="notion-h1">${text}</h1>\n\n`;
      }

      // For toggleable headings, we process children directly
      // This ensures proper content building from bottom up
      const childrenContent = block.children?.length
        ? await Promise.all(
            block.children.map((child) => utils.processBlock(child)),
          )
        : [];

      return `<details>
  <summary>
  <h1>${text}</h1>
  </summary>

  ${childrenContent.join('\n')}

</details>\n`;
    },

  code: {
    transform: async ({ block, utils }) => {
      const code = await utils.processRichText(block.code.rich_text);
      const lang = block.code.language || 'plain';

      return `<CodeBlock language="${lang}">
  ${code}
</CodeBlock>\n\n`;
    },
    imports: [`import { CodeBlock } from '@/components/CodeBlock';`] // added imports
  }

  //...add other block types and their transformers
};

Now let’s update our renderer to use these transformers:

index.ts
1
2
3
4
5
6
7
constructor(config: JSXRendererConfig = {}) {
  super();

  this.addMetadata('config', config);
  this.createAnnotationTransformers(annotationTransformers);
  this.createBlockTransformers(blockTransformers);
}

Adding Variable Resolvers

To handle our template variables (imports, content, and component), we need to create resolvers. Each variable get assiged a default resolver which works fine for imports and content but for component we needs a custom resolver since it has different use case.

resolvers.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { VariableResolver } from 'notion-to-md';

export const createComponentNameResolver = (): VariableResolver => {
  return async (_, context) => {
    // accessing the config we created from context.metadata
    const config = context.metadata.config || {};
    return config.componentName || 'NotionContent';
  };
};

// can define resolvers for content or import or any other use case if needed
// for this default works just fine for content and imports variables.

Tip

Read more about variables and resolvers.

Let’s add these resolvers to our renderer:

index.ts
1
2
3
4
5
6
7
8
9
constructor(config: JSXRendererConfig = {}) {
  super();

  this.addMetadata('config', config);
  this.createAnnotationTransformers(annotationTransformers);
  this.createBlockTransformers(blockTransformers);

  this.addVariable('component', createComponentResolver());
}

Using the Renderer

Now that we have our JSX renderer complete, here’s how to use it:

import { NotionToMarkdown } from 'notion-to-md';
import { JSXRenderer } from './jsx-renderer';

const n2m = new NotionToMarkdown({ notionClient });

// Create instance of JSX renderer with custom config
const jsxRenderer = new JSXRenderer({
  componentName: 'MyNotionContent'
});

// Use the renderer
n2m.setRenderer(jsxRenderer);

// Convert blocks to JSX
const jsx = await n2m.convert(blocks);

This will output JSX code that looks like:

export function MyNotionContent({ components = {} }) {
  return (
    <div className="notion-content">
      <h1 className="notion-h1">Welcome to Notion</h1>
      <p className="notion-paragraph">This is a paragraph with some <strong>bold</strong> text.</p>
      <pre className="notion-code">
        <code className="language-javascript">
          console.log('Hello World');
        </code>
      </pre>
    </div>
  );
}

Next Steps

  • Add more block transformers for other Notion block types
  • Implement custom styling system
  • Add support for interactive components
  • Handle nested blocks (like toggle lists)
  • Add proper TypeScript types for components props

Remember that this is a basic implementation. You can extend it further based on your specific needs by adding more transformers, improving the styling, or adding additional features.