DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The components own the markup, the page owns every word

CogniPrep had two product pages with nothing underneath them: /interview and /assessment-centre-practice. Every other part of the site had a cluster keyed to something a candidate can actually name, a provider, an employer, a test format, and those two had a landing page and a full stop. So every "how to pass a video interview" or "e-tray exercise tips" search went somewhere else, and the two pages had no internal linking feeding them.

Building the cluster is the easy part. Deciding what is shared and what is not is the part that decides whether you still want to touch it in six months. Here is the split we landed on, and the one rule that holds it together.

The rule

A shared component may own markup, mobile behaviour and structured data. It may never own a sentence a visitor reads.

That is written at the top of the primitives file, and it is the whole architecture:

/**
 * These blocks own markup, mobile behaviour and structured data. They do NOT own
 * copy: every string a visitor reads is written in the page.tsx that composes
 * them, which is the point of each guide having its own route folder. A block
 * that wanted to supply its own words would be the wrong shape for this folder.
 */
Enter fullscreen mode Exit fullscreen mode

The failure mode it prevents is the one every content system hits eventually: a component grows a variant prop, then a tone prop, then a lookup table of default headings, and six months later nobody can answer "where does this paragraph come from" without a debugger.

Three layers

Layer one: a registry of metadata only.

export interface GuideMeta {
  slug: string;
  label: string;   // breadcrumbs and sibling pills
  h1: string;      // page heading, also the hub card title
  metaTitle: string;
  metaDescription: string;
  ogTitle: string; // shorter, because social cards truncate harder
  keywords: string[];
  cardBlurb: string; // the blurb on the parent hub's card
}
Enter fullscreen mode Exit fullscreen mode

What is in there is exactly what other pages need to know about this page without rendering it: the hub cards, the sitemap, the sibling pills at the foot of each guide, and the Next.js metadata export. Nothing else. Adding a guide is one entry plus one folder, and forgetting the entry is the loud failure, because the lookup throws at build time rather than rendering an empty card.

One constraint lives as a comment on the field, because it is not expressible in the type:

/**
 * The <title>, before the root layout appends ' | CogniPrep' (12 characters).
 * Keep this at 48 characters or fewer so the rendered title stays under 60.
 */
metaTitle: string;
Enter fullscreen mode Exit fullscreen mode

A title template in the root layout means the budget for the string you write is not the budget search results display. Twelve characters of brand suffix is twelve characters you do not get.

Layer two: one function that turns a registry entry into a Next.js metadata export.

export function acGuideMetadata(slug: string): Metadata {
  return buildGuideMetadata(requireAcGuide(slug), `/assessment-centre-practice/${slug}`);
}
Enter fullscreen mode Exit fullscreen mode

Canonical, robots, OpenGraph and Twitter, identical on every page in the cluster, because forty lines of metadata restated per page is forty lines that will differ per page by accident.

Layer three: a shell that owns the chrome, and nothing below it.

/**
 * Deliberately does NOT own the body, exactly as ArticleShell does not own a
 * blog post's. Each guide writes its own sections in its own page.tsx from the
 * primitives in ./guide-blocks.
 */
Enter fullscreen mode Exit fullscreen mode

The shell takes the registry entry, the parent hub, an intro line, some chips, the lead paragraphs and the contents list, and renders the header, breadcrumb, BreadcrumbList schema and title block. Then it renders children and gets out of the way.

See it: open cogniprep.app/assessment-centre-practice/e-tray-exercise and view source. You will find four JSON-LD blocks: Organization from the root layout, BreadcrumbList from the shell, and HowTo and FAQPage emitted by two of the body blocks. Compare it with the hub at cogniprep.app/assessment-centre-practice, which has Product instead of HowTo, because it is a different kind of page making a different claim.

Structured data belongs to the block, not the page

This is the part I would most recommend copying. A HowTo block should not be a thing an author remembers to add. It should be impossible to render the numbered steps without also emitting the schema for them, because they are the same content:

export function GuideSteps({ name, description, steps }) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'HowTo',
    name,
    description,
    step: steps.map(({ title, body }, index) => ({
      '@type': 'HowToStep',
      position: index + 1,
      name: title,
      text: body,
    })),
  };

  return (
    <>
      <script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
      <ol className="grid gap-4 sm:grid-cols-2">{/* the visible steps */}</ol>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

One array, rendered twice, once for people and once for parsers. They cannot drift, because there is no second copy to drift from. GuideFaqs does the same with FAQPage.

And note the comment on the block itself: the steps section is where these pages get read, so it is the one section whose structured data describes the page honestly rather than dressing prose up as something it is not. Marking up a page as a HowTo because a HowTo gets a rich result is how you end up with a manual penalty. Markup should follow content, not recruit it.

The one block that supplies its own data

There is exactly one exception to the no-data rule, and the reason it is an exception is instructive:

/**
 * Product data rather than page copy, which is why this block derives it
 * instead of taking it as a prop: a page cannot then claim coverage the
 * library does not have.
 */
export function scenariosOfKind(kind: ExerciseKind) {
  return EXERCISE_LIBRARY.filter((exercise) => exercise.kind === kind).map(/* ... */);
}
Enter fullscreen mode Exit fullscreen mode

When a guide says "here are the e-tray scenarios you can practise", that is not a copywriting decision, it is a fact about the product. Passing it as a prop would let a page claim scenarios that do not exist. Deriving it means the page can only ever describe what is really there, and when the library grows the page updates itself.

The same function is also the place where a hard boundary is enforced: it reads only the browse-time fields. Everything else on an exercise is the marking scheme, and no public page renders any of that. A content system that can reach the whole model will eventually reach the wrong part of it, so the safest place to draw that line is the accessor every page has to go through.

See it: the scenario grid on cogniprep.app/assessment-centre-practice/in-tray-exercise lists what exists, and nothing there is typed into the page.

Why each guide gets its own route folder

A dynamic [slug] route with a content registry would be fewer files. It would also mean that the first guide needing a layout nobody else needs has to be lifted out of the shared template first, and that is the moment content systems go bad: the page you want to make better is the page that is hardest to change.

Twelve folders is not a maintenance burden. Twelve pages that all have to agree about their shape, forever, is.

The cluster is live at cogniprep.app/interview and cogniprep.app/assessment-centre-practice. Open two siblings side by side: identical chrome, identical metadata treatment, and not one shared sentence between them.

Top comments (0)