tutorial // Apr 27, 2021

How to Dynamically Add Anchor Tags to HTML with JavaScript

How to dynamically generate and inject anchor links into HTML to improve the UX (user experience) of your blog or content-based app.

How to Dynamically Add Anchor Tags to HTML with JavaScript

A big part of SEO is improving the indexability of your site and ensuring that your content meets the needs of a user's query. One bit of UX (user experience) that you can add—especially if you're authoring long-form content like a blog—is to provide anchor links for different sections of your content.

Doing this by hand is a chore, so in this tutorial, we're going to learn how to automatically traverse some HTML, find all of its h1-h6 tags, and automatically update them to include an anchor link (complete with a slugified version of its text).

Getting Started

To get started, we're going to rely on the CheatCode Next.js Boilerplate to give us a good starting point. First, clone a copy of the boilerplate:

Terminal

git clone https://github.com/cheatcode/nextjs-boilerplate

Then, install the boilerplate's dependencies:

Terminal

cd nextjs-boilerplate && npm install

After those dependencies are installed, install the following dependencies that we'll use later in the tutorial:

Terminal

npm i cheerio commonmark speakingurl

Once these are installed, go ahead and start up the boilerplate:

Terminal

npm run dev

Writing the anchor linker

Before we actually "see" anything on screen, we're going to focus on the core function we need to help us automatically add anchor links to our content. To get started, let's set up a function at /lib/anchorLinker.js where our code will live:

/lib/anchorLinker.js

const anchorLinker = (content = "") => {
  // Our automatic anchor linking will go here.
};

export default anchorLinker;

Simple. Here, we're just creating a skeleton for our function, adding a single content argument that we expect to be a string. The content = "" syntax here is saying "if there is no value passed for content, assign it a default value of an empty string."

/lib/anchorLinker.js

import isClient from "./isClient";

const anchorLinker = (content = "") => {
  if (isClient) {
    // Client-side linking will go here.
  }

  // Server-side linking will go here.
};

export default anchorLinker;

Next, we've introduced an if statement, checking to see if isClient is true (isClient is added as an import up top and is a function automatically included in the boilerplate at /lib/isClient.js). We've added this here because, even though we're working with a front-end only boilerplate, Next.js—the framework the boilerplate is built on top of—has a server-side rendering feature to generate HTML for search engines.

It does this via a function called getServerSideProps(). This function runs when an initial request comes in to a Next.js-based app. Before that request receives a response in the form of HTML in the browser, first, Next.js calls getServerSideProps() to aid in data fetching and other server-side tasks before returning HTML to the request.

Because this function runs in the context of a server, certain browser-level APIs (e.g., DOM manipulation methods) are unavailable. So, when this code runs in that context, it throws an error. To get around this, we're going to write two sets of code here: a client-side implementation of our anchor linker and a server-side implementation of our anchor linker.

Adding client-side anchor linking

For the client, we have full access to the browser DOM manipulation APIs, so we don't need to bring in any special dependencies or code:

/lib/anchorLinker.js

import isClient from "./isClient";
import parseMarkdown from "./parseMarkdown";

const anchorLinker = (content = "") => {
  if (isClient) {
    const html = document.createElement("div");
    html.innerHTML = parseMarkdown(content);
  }

  // Server-side linking will go here.
};

export default anchorLinker;

First, in order to isolate the HTML generated from our content string, we use the document.createElement() method to create a <div></div> element (in-memory, not rendered to screen). Next, we populate that <div></div> with the result of calling parseMarkdown(), passing in our content.

Real quick, let's add that function so we can fulfill the import up top:

/lib/parseMarkdown.js

import { Parser, HtmlRenderer } from "commonmark";

const parseMarkdown = (markdown = "", options) => {
  if (markdown) {
    const reader = new Parser();
    const writer = options ? new HtmlRenderer(options) : new HtmlRenderer();
    const parsed = reader.parse(markdown);
    return writer.render(parsed);
  }

  return "";
};

export default parseMarkdown;

Markdown is a short-hand language for generating HTML from text files using a special syntax. So we can skip having to write a bunch of HTML tags for our test, we'll use Markdown to automatically generate the HTML for us. Here, parseMarkdown() is a function that wraps around the commonmark library. Commonmark is a Markdown parser that takes in a string and converts it into HTML, per the Markdown specification.

The details here are limited as this is just following the instructions in the commonmark documentation on how to use the parser. To use it, we create an instance of the Parser followed by creating an instance of the HtmlRenderer. Here, we conditionally call new HtmlRenderer based on whether or not a value was passed to the second options argument of our parseMarkdown function (these are the options for commonmark, if necessary).

With our HtmlRenderer configured and stored in the writer variable, next, we parse our markdown string to a virtual DOM (document object model) and then use writer.render() to convert that DOM into an HTML string.

/lib/anchorLinker.js

import cheerio from "cheerio";
import isClient from "./isClient";
import parseMarkdown from "./parseMarkdown";
import getSlug from "./getSlug";

const anchorLinker = (content = "") => {
  if (isClient) {
    const html = document.createElement("div");
    html.innerHTML = parseMarkdown(content);

    const hTags = html.querySelectorAll("h1, h2, h3, h4, h5, h6");

    hTags.forEach((hTag) => {
      const tagContent = hTag.innerHTML;
      const tagSlug = getSlug(tagContent);

      hTag.innerHTML = `<a class="anchor-link" href="#${tagSlug}"><i class="fas fa-link"></i></a> ${tagContent}`;
      hTag.setAttribute("id", tagSlug);
    });

    return html.innerHTML;
  }
};

export default anchorLinker;

With our Markdown parsed to HTML, now we can get into the meat of this tutorial. Back in our /lib/anchorLinker.js file, we've expand the if (isClient) block of our anchorLinker() function to start the anchor linking process.

In order to automatically link all of the h1-h6 tags in our content, we need to retrieve those elements from the <div></div> we created earlier and then populate it with the result of parsing our Markdown to HTML in parseMarkdown().

Using html.querySelectorAll("h1, h2, h3, h4, h5, h6"), we say "go and get us all of the h1-h6 tags inside of this HTML." This gives us back a JavaScript DOM node list containing all of our h1-h6 tags. With this, next, we call to hTags.forEach() running a loop over each of the discovered h1-h6 tags.

In the callback for our forEach() we do the work necessary to "autolink" our tags. To do it, first, we grab the unmodified content of the tag (this is the text in the tag, e.g., the "This is an h1 anchor" in <h1>This is an h1 anchor</h1>) via hTag.innerHTML where hTag is the current tag in the hTags array that we're looping over.

With that content, next, we introduce a new function getSlug() to help us create the slugified, URL-safe version of our tag's content like this-is-an-h1-anchor. Let's look at that function quick and discuss how it's working:

/lib/getSlug.js

import speakingUrl from "speakingurl";

const getSlug = (string = "") => {
  return speakingUrl(string, {
    separator: "-",
    custom: { "'": "" },
  });
};

export default getSlug;

In this file, all we're doing is creating a wrapper function around the speakingurl dependency we installed at the start of the tutorial. Here, speakingUrl() is a function that takes in a string and converts it to a-hyphenated-slug-like-this. That's it!

/lib/anchorLinker.js

import cheerio from "cheerio";
import isClient from "./isClient";
import parseMarkdown from "./parseMarkdown";
import getSlug from "./getSlug";

const anchorLinker = (content = "") => {
  if (isClient) {
    const html = document.createElement("div");
    html.innerHTML = parseMarkdown(content);

    const hTags = html.querySelectorAll("h1, h2, h3, h4, h5, h6");

    hTags.forEach((hTag) => {
      const tagContent = hTag.innerHTML;
      const tagSlug = getSlug(tagContent);

      hTag.innerHTML = `<a class="anchor-link" href="#${tagSlug}"><i class="fas fa-link"></i></a> ${tagContent}`;
      hTag.setAttribute("id", tagSlug);
    });

    return html.innerHTML;
  }
};

export default anchorLinker;

Jumping back to our /lib/anchorLinker.js file, now we're prepared to create our anchor link. Here, we take the current hTag we're looping over and modify its innerHTML (meaning the contents of the hTag, but not the hTag itself) to include an <a></a> tag wrapped around a link icon (taken from the Font Awesome library included in the Next.js boilerplate we're using).

In addition to that, if we look close, we'll notice that for the <a></a> tag we're adding, we set the href attribute equal to #${tagSlug}. This is important. Here, the # part of that is what tells the web browser that the following text represents the id of an element on the page. When typed into the URL bar, this will trigger the browser to look for an element with that id on the page and scroll the user down to it. This is why it's called an "anchor" link: it's anchoring the URL to that specific point in the content.

To set the id, we use hTag.setAttribute() to set the id on the hTag that we're currently looping over. We set this here (as opposed to on the <a></a> tag) because we're trying to anchor the user directly to the content, not the link itself.

After this, we finish out our if (isClient) block out by returning html.innerHTML, or, our content converted to HTML and updated to include our anchor tags (what we'll render on screen).

Adding server-side anchor linking

Before we put this to use, recall that earlier we mentioned needing to also handle this linking for server-side rendering. The concept here is the same, but the method we'll use for doing it is different (again, the server-side environment does not have access to DOM manipulation APIs like document.querySelectorAll() or hTag.setAttribute()).

To help us out, we're going to rely on the cheerio dependency that we installed at the beginning of this tutorial. Cheerio is a server-side, Node.js-friendly DOM manipulation library. Since we already understand the mechanics at play here, let's add the code we need to do what we just did above using cheerio and step through it:

/lib/anchorLinker.js

import cheerio from "cheerio";
import isClient from "./isClient";
import parseMarkdown from "./parseMarkdown";
import getSlug from "./getSlug";

const anchorLinker = (content = "") => {
  if (isClient) {
    [...]

    return html.innerHTML;
  }

  const $ = cheerio.load("<div></div>");
  $("div").html(content);

  const hTags = $("body").find("h1, h2, h3, h4, h5, h6");

  hTags.each(function () {
    const tagContent = $(this).text();
    const tagSlug = getSlug(tagContent);

    $(this).html(
      `<a class="anchor-link" href="#${tagSlug}"><i class="fas fa-link"></i></a> ${tagContent}`
    );
    $(this).attr("id", tagSlug);
  });

  return $("body div").html();
};

export default anchorLinker;

Again, the idea here is identical to what we learned above. The only real difference is the means by which we're implementing the code. Because we return inside of our isClient block, we can skip an else block and just return the server anchor-linking code directly from our function body. This works because if (isClient) is true, when JavaScript hits the return statement, it will cease to evaluate any code beyond that point. If it's false, it will skip that block and go on to our server-side code.

Focusing in on that code, we begin by creating our in-memory DOM using cheerio.load("<div></div>") creating an empty <div></div> just like we did above. We store this in a $ variable because cheerio is technically "jQuery for Node.js" (that's in quotes because the only "jQuery" thing about Cheerio is that it's API was influenced by jQuery—we're not using any jQuery code here).

Similar to above, we use the $("body") function to say "find the body tag inside of the $ DOM that we just generated and then within that locate any h1-h6 tags." This should look familiar. This is identical to what we did with document.querySelectorAll() earlier.

Next, we take our tags and loop over them. For each tag, again, we extract the inner text content of the tag, convert it to a slug with getSlug() and then inject the "anchored" <a></a> tag back into the hTag and finally, set the id attribute. The only thing that may be confusing here is the usage of this instead of hTag like we saw in our .forEach() loop on the client.

Here, this refers to the current context within which the hTags.each() loop is running (meaning the current element it's looping over). Though we can't see it, this is being set by Cheerio behind the scenes.

Finally, immediately after our .each() loop, we return the HTML contents of the <div></div> tag we created with cheerio.load().

Done! Now, we're ready to put this to use and see some anchor links being added to our HTML.

Connecting the anchor linker to HTML

To demo usage of our new anchorLinker() function, we're going to wire up a simple component with some Markdown text including some h1-h6 tags in-between some lorem ipsum paragraphs:

/pages/index.js

import React from "react";
import anchorLinker from "../lib/anchorLinker";

import StyledIndex from "./index.css";

const paragraphs = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`;

const testContent = `This is some test content to verify our anchorLinker() is working.

# This is an h1 anchor
${paragraphs}

## This is an h2 anchor
${paragraphs}

### This is an h3 anchor
${paragraphs}

#### This is and h4 anchor
${paragraphs}

##### This is an h5 anchor
${paragraphs}

###### This is an h6 anchor
${paragraphs}
`;

const Index = ({ prop1, prop2 }) => (
  <StyledIndex
    dangerouslySetInnerHTML={{
      __html: anchorLinker(testContent),
    }}
  />
);

Index.propTypes = {};

export default Index;

Here, the part we want to pay attention to is the React component near the bottom of the file starting with const Index = () => {}. Here, we return a styled component <StyledIndex /> that helps us to set some basic styles for our content (this is imported up at the top from ./index.css). We won't go into the details of the styles here, but let's add those in now to avoid confusion:

/pages/index.css.js

import styled from "styled-components";

export default styled.div`
  .anchor-link {
    color: #aaa;
    font-size: 18px;

    &:hover {
      color: var(--primary);
    }

    .fa-link {
      margin-right: 5px;
    }
  }

  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    font-size: 20px;
    margin-bottom: 20px;
  }

  p {
    font-size: 16px;
    line-height: 26px;
    margin-bottom: 40px;
  }
`;

Note: The .css.js suffix on the file name here is intentional. We're creating our CSS using styled-components which is done via JavaScript and we name it this way to imply that the contents of the file are "CSS written in JavaScript."

/pages/index.js

import React from "react";
import anchorLinker from "../lib/anchorLinker";

import StyledIndex from "./index.css";

const paragraphs = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`;

const testContent = `This is some test content to verify our anchorLinker() is working.

# This is an h1 anchor
${paragraphs}

[...]
`;

const Index = ({ prop1, prop2 }) => (
  <StyledIndex
    dangerouslySetInnerHTML={{
      __html: anchorLinker(testContent),
    }}
  />
);

Index.propTypes = {};

export default Index;

Back in our test <Index /> component, as a prop on our <StyledIndex /> component, we set dangerouslySetInnerHTML equal to an object with an __html property containing the result of calling our imported anchorLinker() function and passing our testContent string (our uncompiled Markdown).

Remember, inside of anchorLinker(), we're returning a string of HTML from both our client and server-side versions of the linker. So, when that ultimately returns, here, we take that HTML string and set it as the contents of the rendered <StyledIndex /> element in React.

In other words? This will render the anchor-linked version of our HTML in the browser:

Wrapping up

In this tutorial, we learned how to automatically generate anchor tags for our HTML content. We learned how to select and manipulate DOM elements in memory, generating an HTML string containing our anchor links and rendering it in the browser.

We also learned how to utilize Markdown to generate HTML on the fly for us via commonmark as well as how to generate slugified strings with speakingurl.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode