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 }[]
}
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
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
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>
))}
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>
))}
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 }[]
}
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')
"
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)
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?