engineering

Build Dynamic Open Graph (OG) Images

Create unique images for each blog post dynamically with Next.js

Last Edited on

11 min read

When creating content for the web, ensuring that it looks great when shared across social media platforms is essential. Loading ... play a crucial role in defining how your webpage appears when shared on platforms like Facebook, Twitter, or LinkedIn. These tags provide metadata about your webpage, including its title, description, and image, influencing how users perceive and engage with your content.

Loading ... images are a powerful part of this metadata. Specified using the og:image meta tag, they allow you to customize the visual content displayed alongside links to your site. By setting up dynamic OG images, you can make your blog posts more visually appealing, relevant, and engaging for your audience.

<meta property="og:image" content="https://example.com/path-to-image.jpg" />

This guide will show you how to dynamically generate OG images for your blog using Loading ..., enabling each post to have a unique, shareable image that stands out.

Setting Up Dynamic OG Images in Next.js

Install Dependencies

First, ensure you have a Next.js project set up. Install the necessary dependencies for working with images and fonts:

npm install @vercel/og @vercel/node

The @vercel/og package allows you to generate images on the server-side, which is crucial for creating dynamic OG images.

This inline code console.log("Hello World") will be highlighted.

Step 2: Build an API Route for OG Images

Next, create a dynamic API route in your Next.js project to serve OG images. Since I am using the new app structure in Next.js, I will create a new file in the app/api/og directory called route.js.

I have a Loading ... setup in my project, which allows me to fetch all the blog posts from my content directory. I will use this data to generate the OG images dynamically.

app/api/og/route.js
import { ImageResponse } from '@vercel/og';
import { allPosts } from 'contentlayer/generated';
 
import siteMetadata from '@/data/meta/metadata';
import { coreContent } from '@/lib/utils/contentlayer';
 
export async function GET(request) {
  try {
 
    const slug = request.nextUrl.searchParams.get('slug').replace('category/', '');
    const posts = coreContent(allPosts);
    const postIndex = posts.findIndex((_post) => _post.slug.replace('category/', '') === slug);
    let post = allPosts[postIndex];
 
    if (!post) post = {
      'category': slug,
      'subtitle': siteMetadata.description,
      'title': siteMetadata.title
    };
 
    return new ImageResponse(
      (
        ....
      )
    );
  } catch (error) {
    console.log(`${error.message}`);
 
    return new Response(`Failed to generate the image`, {
      'status': 500
    });
  }
}

Line Numbers and Line Highlighting

Draw attention to a particular line of code.

import { useFloating } from "@floating-ui/react";
 
function MyComponent() {
  const { refs, floatingStyles } = useFloating();
 
  return (
    <>
      <div ref={refs.setReference} />
      <div ref={refs.setFloating} style={floatingStyles} />
    </>
  );
}

Word Highlighting

Draw attention to a particular word or series of characters.

import { useFloating } from "@floating-ui/react";
 
function MyComponent() {
  const { refs, floatingStyles } = useFloating();
 
  return (
    <>
      <div ref={refs.setReference} />
      <div ref={refs.setFloating} style={floatingStyles} />
    </>
  );
}

Inline Code Highlighting

The result of [1, 2, 3].join('-') is '1-2-3'.

Context Aware Inline Code

For instance, if you had the following code block:

function getStringLength(str) {
  return str.length;
}

When we refer to getStringLength as a plain variable, we can color it as a function. Same with function, or str vs. str, etc. This allows you to semantically tie inline code with the nearest code block it's referring to.

ANSI Highlighting

  vite v5.0.0 dev server running at:
 
  > Local: http://localhost:3000/
  > Network: use `--host` to expose
 
  ready in 125ms.
 
8:38:02 PM [vite] hmr update /src/App.jsx

Inline ANSI: > Local: http://localhost:3000/


This package is ESM-only and currently supports shiki ^1.0.0.

To use the latest version in Next.js, ensure your config file is ESM: next.config.mjs. Here's a full example: Loading ...

Usage

The following works both on the server and on the client.

import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypePrettyCode from "rehype-pretty-code";
 
async function main() {
  const file = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypePrettyCode, {
      // See Options section below.
    })
    .use(rehypeStringify)
    .process("`const numbers = [1, 2, 3]{:js}`");
 
  console.log(String(file));
}
 
main();

Note that unified v11 is used as a dependency internally. Ensure your unified version is compatible.

MDX

The following example shows how to use this package with Next.js.

next.config.mjs
import nextMDX from "@next/mdx";
import rehypePrettyCode from "rehype-pretty-code";
 
/** @type {import('rehype-pretty-code').Options} */
const options = {
  // See Options section below.
};
 
const withMDX = nextMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [],
    rehypePlugins: [[rehypePrettyCode, options]],
  },
});
 
/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true };
 
export default withMDX(nextConfig);

Disable the mdxRs option if Rehype plugins do not work.

Styles

This library is unstyled. This means it does not provide a CSS file that applies styling by default. Rather, it provides logical attributes that you use to apply the styling yourself.

For instance, the following CSS prevents overflow and adds padding:

pre {
  overflow-x: auto;
  padding: 1rem 0;
}
 
pre [data-line] {
  padding: 0 1rem;
}

Features including highlighted lines, chars, and line numbers expose data attributes that enable you to add your own styling, which are explained below.

Options

interface Options {
  grid?: boolean;
  theme?: Theme | Record<string, Theme>;
  keepBackground?: boolean;
  bypassInlineCode?: boolean;
  defaultLang?: string | { block?: string; inline?: string };
  tokensMap?: Record<string, string>;
  transformers?: ShikiTransformer[];
  filterMetaString?(str: string): string;
  getHighlighter?(options: BundledHighlighterOptions): Promise<Highlighter>;
  onVisitLine?(element: LineElement): void;
  onVisitHighlightedLine?(element: LineElement): void;
  onVisitHighlightedChars?(element: CharsElement, id: string | undefined): void;
  onVisitTitle?(element: Element): void;
  onVisitCaption?(element: Element): void;
}

grid

A grid style is present by default which allows line highlighting to span the entire width of a horizontally-scrollable code block.

You can disable this setting if necessary:

const options = {
  grid: false,
};

theme

The default theme is github-dark-dimmed. Shiki has a bunch of Loading ..., which can be specified as a plain string:

const options = {
  theme: "one-dark-pro",
};

You can use your own theme as well by passing the theme JSON:

const options = {
  theme: JSON.parse(fs.readFileSync("./themes/moonlight-ii.json", "utf-8")),
};

keepBackground

To apply a custom background instead of inheriting the background from the theme:

const options = {
  keepBackground: false,
};

bypassInlineCode

Skip inline code highlighting:

const options = {
  bypassInlineCode: true,
};

defaultLang

When no code language is specified, inline code or code blocks will not be themed (nor will the background), which may appear incongruous with others.

In this case, you can specify a default language:

const options = {
  defaultLang: "plaintext",
};

Or you can also specify default languages for inline code and code blocks separately:

const options = {
  defaultLang: {
    block: "plaintext",
    inline: "plaintext",
  },
};

transformers

Loading ... are a Shiki-native way to manipulate the hAST tree of the code blocks and further extend the behavior of this plugin. The Loading ... package provides some useful transformers.

import { transformerNotationDiff } from '@shikijs/transformers';
 
const options = {
  transformers: [transformerNotationDiff()],
};

Meta Strings

Code blocks are configured via the meta string on the top codeblock fence.

If your library also parses code blocks' meta strings, it may Loading ... with rehype-pretty-code. This option allows you to filter out some part of the meta string before the library starts parsing it.

const options = {
  filterMetaString: (string) => string.replace(/filename="[^"]*"/, ""),
};

Highlight Lines

Place a numeric range inside {}.

```js {1-3,4}
 
```

Styling: The line <span> receives a data-highlighted-line attribute that enables you to style via CSS.

Group Highlighted Lines By Id

Place an id after # after the {}. This allows you to color or style lines differently based on their id.

```js {1,2}#a {3,4}#b
 
```

Styling: The line <span> receives a data-highlighted-line-id="<id>" attribute that enables you to style via CSS.

Highlight Chars

You can use either /:

```js /carrot/
 
```

Or " as a delimiter:

```js "carrot"
 
```

Different segments of chars can also be highlighted:

```js /carrot/ /apple/
 
```

Styling: The chars <span> receives a data-highlighted-chars attribute to style via CSS.

To highlight only the third to fifth instances of carrot, a numeric range can be placed after the last /.

```js /carrot/3-5
 
```

Highlight only the third to fifth instances of carrot and any instances of apple.

```js /carrot/3-5 /apple/
 
```

Group Highlighted Chars By Id

Place an id after # after the chars. This allows you to color chars differently based on their id.

```js /age/#v /name/#v /setAge/#s /setName/#s /50/#i /"Taylor"/#i
const [age, setAge] = useState(50);
const [name, setName] = useState("Taylor");
```
const [age, setAge] = useState(50);
const [name, setName] = useState("Taylor");

Styling: The chars <span> receives a data-chars-id="<id>" attribute to style via CSS.

Highlight Inline Code

Append {:lang} (e.g. {:js}) to the end of inline code to highlight it like a regular code block.

This is an array `[1, 2, 3]{:js}` of numbers 1 through 3.

Highlight Plain Text

Append {:.token} to the end of the inline code to highlight it based on a token specified in your VS Code theme. Tokens start with a . to differentiate them from a language.

The name of the function is `getStringLength{:.entity.name.function}`.

You can create a map of tokens to shorten this usage throughout your docs:

const options = {
  tokensMap: {
    fn: "entity.name.function",
  },
};
The name of the function is `getStringLength{:.fn}`.

Titles

Add a file title to your code block, with text inside double quotes (""):

```js title="..."
 
```

Captions

Add a caption underneath your code block, with text inside double quotes (""):

```js caption="..."
 
```

Line Numbers

CSS counters can be used to add line numbers.

code[data-line-numbers] {
  counter-reset: line;
}
 
code[data-line-numbers] > [data-line]::before {
  counter-increment: line;
  content: counter(line);
 
  /* Other styling */
  display: inline-block;
  width: 0.75rem;
  margin-right: 2rem;
  text-align: right;
  color: gray;
}
 
code[data-line-numbers-max-digits="2"] > [data-line]::before {
  width: 1.25rem;
}
 
code[data-line-numbers-max-digits="3"] > [data-line]::before {
  width: 1.75rem;
}
 
code[data-line-numbers-max-digits="4"] > [data-line]::before {
  width: 2.25rem;
}

If you want to conditionally show them, use showLineNumbers:

```js showLineNumbers
 
```

Styling: <code> will have attributes data-line-numbers and data-line-numbers-max-digits="n".

If you want to start line numbers at a specific number, use showLineNumbers{number}:

```js showLineNumbers{number}
 
```

Multiple Themes (Dark and Light Mode)

Pass your themes to theme, where the keys represent the color mode:

const options = {
  theme: {
    dark: "github-dark-dimmed",
    light: "github-light",
  },
};

Now, use the following CSS to display the variable colors — if a space is found in the theme name, then CSS variable keys based on the object are available (Loading ...):

code[data-theme*=" "],
code[data-theme*=" "] span {
  color: var(--shiki-light);
  background-color: var(--shiki-light-bg);
}
 
@media (prefers-color-scheme: dark) {
  code[data-theme*=" "],
  code[data-theme*=" "] span {
    color: var(--shiki-dark);
    background-color: var(--shiki-dark-bg);
  }
}

The <code> and <pre> elements will have the data attribute data-theme="...themes", listing each theme value space-separated:

<code data-theme="github-dark-dimmed github-light"></code>

Visitor Hooks

To customize the HTML output, you can use visitor callback hooks to manipulate the Loading ... directly:

const options = {
  onVisitLine(element) {
    console.log("Visited line");
  },
  onVisitHighlightedLine(element) {
    console.log("Visited highlighted line");
  },
  onVisitHighlightedChars(element) {
    console.log("Visited highlighted chars");
  },
  onVisitTitle(element) {
    console.log("Visited title");
  },
  onVisitCaption(element) {
    console.log("Visited caption");
  },
};

Custom Highlighter

To completely configure the highlighter, use the getHighlighter option. This is helpful if you'd like to configure other Shiki options, such as langs.

import { getHighlighter } from "shiki";
 
const options = {
  getHighlighter: (options) =>
    getHighlighter({
      ...options,
      langs: [
        "plaintext",
        async () => JSON.parse(await readFile("my-grammar.json", "utf-8")),
      ],
    }),
};

React Server Component

The usage works directly in React Server Components. Here's an example:

code.tsx
import * as React from "react";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypePrettyCode from "rehype-pretty-code";
 
export async function Code({ code }: { code: string }) {
  const highlightedCode = await highlightCode(code);
  return (
    <section
      dangerouslySetInnerHTML={{
        __html: highlightedCode,
      }}
    />
  );
}
 
async function highlightCode(code: string) {
  const file = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypePrettyCode, {
      keepBackground: false,
    })
    .use(rehypeStringify)
    .process(code);
 
  return String(file);
}

Then, import the RSC into a page or another component:

import * as React from "react";
import { Code } from "./code.tsx";
 
export default async function Page() {
  return (
    <main>
      <Code code="`const numbers = [1, 2, 3]{:js}`" />
    </main>
  );
}
The opinions and views expressed on this blog are solely my own and do not reflect the opinions, views, or positions of my employer or any affiliated organizations. All content provided on this blog is for informational purposes only