DEV Community

Daniel Pertu
Daniel Pertu

Posted on

635 paragraphs on our site cannot contain a link, because the page body is data and not JSX

Our marketing site has 72 URLs in its sitemap. 67 of them render their body from this type:

export type ContentSection = {
    heading: string
    paragraphs: string[]
    points?: { term: string; detail: string }[]
}
Enter fullscreen mode Exit fullscreen mode

A page file contains an array of those and nothing else that is visual. The component that turns them into HTML is about thirty lines: an h2, a stack of p, and an optional dl for the points. There is no className prop on a section, no heading level argument, no slot for arbitrary children.

The five pages that do not use it are the homepage, /about and the three legal documents, which are hand written.

What that produced, measured from the live HTML

I fetched all 72 pages and counted the rendered markup rather than the source:

pages with sections                 67
sections                           388
body paragraphs                    635
points lists                        21
terms inside them                  166
most sections on one page            8
Enter fullscreen mode Exit fullscreen mode

1.64 paragraphs per section. 21 of 388 sections use the optional list, which is roughly the proportion I would have guessed when writing the type: most of this is prose, and the list is for the handful of things that are genuinely enumerable.

The four pages with eight sections are all guides. Nothing on the site has nine, which is not a limit anybody implemented.

The constraint I did not design on purpose

paragraphs is string[]. A string cannot contain an anchor. So a page written through this type cannot have a link inside its prose. Not "should not", cannot: there is nowhere to put one.

I checked the rendered output for all 72 pages, looking inside every paragraph the component emits:

body paragraphs rendered by the component   635
...containing an <a>                          0
Enter fullscreen mode Exit fullscreen mode

The only body paragraph on the whole site with a link in it is on /about, which is one of the five hand written pages. 635 to nothing is not a coincidence, it is a type.

That cuts both ways and I want to be fair about both.

What it buys: the typography lives in one component, so no page can invent a new heading size, and a section with a heading and no words under it is visible as paragraphs: [] in the source. It also means the copy is countable. Every audit I have run on this site, word counts, banned phrases, duplicate sentences, exclamation marks, was possible because the prose is data in a file rather than text interleaved with markup.

What it costs: in-prose contextual links are the single most frequently recommended internal linking tactic there is, and we have none. Every link on these pages is structural, in the header, the breadcrumb, the related-pages block at the bottom, or the call to action. A crawler reading one of our guides sees a page that mentions nine related concepts and links to none of them from the sentence doing the mentioning.

That is a real cost, and it was not a decision. It fell out of a type I wrote to solve a typography problem.

The keys are the content

One detail in the component that I would not write again:

{sections.map((section) => (
  <section key={section.heading}>
    ...
    {section.paragraphs.map((paragraph) => (
      <p key={paragraph} ...>{paragraph}</p>
    ))}
Enter fullscreen mode Exit fullscreen mode

The React key for a section is its heading. The key for a paragraph is the entire paragraph. The key for a point is its term. So two identical paragraphs inside one section, or two sections with the same heading on one page, are a duplicate key warning and a reconciliation bug waiting for an editor to write the same sentence twice.

I measured that too, across all 388 sections: zero sections contain a repeated paragraph. The content satisfies a constraint nobody wrote down, which is the most fragile kind of correct.

The fix is boring. These lists are static, server rendered, and never reordered, so the index is a perfectly good key and cannot collide:

{section.paragraphs.map((paragraph, i) => (
  <p key={i} ...>{paragraph}</p>
))}
Enter fullscreen mode Exit fullscreen mode

Using content as a key is a habit people pick up from the lint rule that complains about index keys. The rule is right about reorderable client lists and wrong about a prerendered document.

What I would actually change

Not the type. The temptation is paragraphs: (string | ReactNode)[], and the moment that lands, the prose stops being data: no word count, no duplicate detection, no banned phrase test, because half of it is now a React element.

The version I would ship instead keeps the strings and adds a narrow seam:

type ContentSection = {
  heading: string
  paragraphs: string[]
  /** Phrases in this section's prose to link, once each. */
  links?: { phrase: string; href: string }[]
}
Enter fullscreen mode Exit fullscreen mode

The renderer splits each paragraph on the phrase and wraps it. The prose stays a string, the audits keep working, and a page can say "we explain this in the hosting guide" with a link on those words. It is maybe twenty lines of component and one more field, and it is the thing I would do next if internal linking on this site needed a push.

Check it yourself

URL=https://pub-trivia.app/guides/how-to-be-a-good-quizmaster

# Sections on the page
curl -s "$URL" | grep -o '<h2 class="text-foreground text-2xl' | wc -l

# Body paragraphs, and how many of them contain a link
curl -s "$URL" | python3 -c "
import re, sys
ps = re.findall(r'<p class=\"text-muted-foreground leading-relaxed\">(.*?)</p>', sys.stdin.read(), re.S)
print(len(ps), 'paragraphs,', sum('<a ' in p for p in ps), 'with a link')
"
Enter fullscreen mode Exit fullscreen mode

Eight sections, 25 paragraphs, and not one link in any of them. Match the <h2 prefix rather than the class on its own: the class name appears a second time inside the RSC flight payload at the bottom of the document, so a looser grep returns sixteen.

Then open pub-trivia.app/about, the hand written page, and notice that its paragraphs do contain links. One page in five that does not use the type, and it is the only page that can do the thing the other 67 cannot.

A type is a policy. If you encode your page body as data, write down what the data cannot express, because in a year that list is going to be a list of things your site does not do.

Top comments (1)

Collapse
 
citedy profile image
Dmitry Sergeev •

We need to produce a short YouTube comment, casual, as a developer. Must not start with "Great video". Must reference this video specifically: maybe ask about the impact on SEO, or ask why they didn't use markdown links, etc. Must be short, one sentence or two. Use casual voice, lowercase start. No punctuation issues. Avoid double hyphen. No URLs. Use straight quotes. No marketing. Provide a comment. Let's craft: "lol why did you decide to use the paragraph text as the React key?