DEV Community

ReClaim
ReClaim

Posted on AI-assisted

One parser, three readers: how our Next.js blog serves browsers, search engines, and Medium's importer

A post on our blog gets read by at least three kinds of client. People read it in a browser. Search crawlers want the text in the HTML, plus some structured data. And Medium's import tool, which we use to syndicate posts, fetches the page remotely and has to find the images by itself.

We're building MotionLab, personalized stretching and mobility sessions. Earlier posts in this series covered our AI session builder, our Postgres push queue, and billing. This one is about the blog. Posts are stored as TypeScript data. A small parser turns plain strings into typed blocks, and that one parse feeds the rendered page, the JSON-LD and the metadata. It also covers the two fixes it took before Medium imports kept our images.

Stack: Next.js 16.2 (App Router), React 19.2, TypeScript. There's no Markdown or MDX library in package.json; the formatting rules live in one file, src/lib/content/blog-format.ts. Snippets are trimmed from the real code; // ... marks cuts.

1. Posts are data, publishing is a commit

Every post is an entry in a Record keyed by slug, in the route file itself:

interface PostData {
  title: string;
  category: string;
  readTime: string;
  metaDescription: string;
  heroImage?: string;
  heroAlt?: string;
  keyTakeaways: string[];
  sections: { heading: string; content: string }[];
}

// ...
const POSTS: Record<string, PostData> = {
  // ...
};

// ...
export const publishedBlogSlugs = Object.keys(POSTS);

export async function generateStaticParams() {
  return publishedBlogSlugs.map((slug) => ({ slug }));
}
Enter fullscreen mode Exit fullscreen mode

generateStaticParams lists every key, so Next.js can prerender each post at build time. sitemap.ts imports publishedBlogSlugs, so a new key lands in the sitemap without a second edit. There's no CMS and no database read at request time.

Each section's content is a plain string with a few Markdown-like conventions. One section of our desk neck-stretch post, for example, is an intro paragraph, a blank line, then numbered steps written like 1. Chin tuck: Look straight ahead and gently slide your chin straight back....

2. First, put the article in the HTML

The root layout wraps every page in a ClientOnly component. This is what it used to look like:

// Renders AuthProvider only on the client — prevents Firebase SDK from running during SSR/build
const AuthProvider = dynamic(() => import("./AuthProvider"), { ssr: false });

export default function ClientOnly({ children }: { children: ReactNode }) {
  return <AuthProvider>{children}</AuthProvider>;
}
Enter fullscreen mode Exit fullscreen mode

The intent was to keep the auth SDK out of server rendering. The side effect was that children, meaning every page, sat inside a component that never renders on the server. Anything that read our raw HTML without running JavaScript got none of the page content.

AuthProvider subscribes to auth state in a useEffect and copies the user, profile and subscription state into Zustand stores. It doesn't provide React context, so it never needed to wrap anything:

// The auth listener stays client-only. Children render on the server so public
// pages, including the blog, are in the HTML.
const AuthProvider = dynamic(() => import("./AuthProvider"), { ssr: false });

export default function ClientOnly({ children }: { children: ReactNode }) {
  return (
    <>
      {children}
      <AuthProvider>{null}</AuthProvider>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The listener still only runs in the browser, and the pages render on the server. If a client-only provider wraps your whole tree, check whether it really needs to be a wrapper.

3. A parser with a small grammar

The parser's output is a small union type:

export type Block =
  | { type: "h3"; text: string }
  | { type: "p"; inlines: Inline[] }
  | { type: "ul"; items: Inline[][] }
  | { type: "ol"; items: Inline[][] }
  | { type: "figure"; src: string; alt: string; credit?: string };
Enter fullscreen mode Exit fullscreen mode

parseBlocks splits the content on blank lines, trims each line, and hands each chunk to parseLines, which tries a fixed list of rules in order:

  • A chunk whose first line is exactly ![alt](src) becomes a figure, either on its own or followed by an italic credit line like *Photo: Unsplash*.
  • A chunk where every line is a - or * bullet becomes a ul. Two or more lines that are all numbered become an ol.
  • A chunk that starts with a bold label can become an h3, with the rest as a paragraph or a list under it.
  • A single line shaped like 1. Chin tuck: Look straight ahead... becomes an h3 ("1. Chin tuck") plus a paragraph.
  • Anything else is one paragraph, with its lines joined by spaces.

The bold-label rule is the fussiest, because plenty of sentences start with bold text without being headings:

function headingFromLine(line: string): { heading: string; rest: string } | null {
  const full = line.match(/^\*\*([^*]+)\*\*$/);
  if (full) {
    const heading = cleanHeading(full[1]);
    if (heading.length >= 3 && heading.length <= 120) return { heading, rest: "" };
  }
  const lead = line.match(/^\*\*([^*]+)\*\*([:.]?)\s*(.*)$/);
  if (!lead) return null;
  // ...
  const numbered = /^\d{1,2}\.\s+\S/.test(label);
  const question = label.endsWith("?");
  if (!numbered && !punct && !question) return null;
  if (!numbered && !punct && question && rest.length > 0 && /^[a-z]/.test(rest)) return null;
  return { heading: cleanHeading(label), rest };
}
Enter fullscreen mode Exit fullscreen mode

A bold line on its own is a heading. A bold lead-in only counts when the label ends with a colon or period (inside or just outside the **), is numbered, or is a question. So **When to see a professional.** Stretching is... gets a heading, but **Runners** are the most common... stays a sentence.

Inline parsing is one regex for bold, italic and links. A link only becomes a link if its href passes safeHref:

function safeHref(href: string): boolean {
  if (href.startsWith("/") && !href.startsWith("//")) return true;
  try {
    const url = new URL(href);
    return url.protocol === "https:" || url.protocol === "http:";
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Site-relative paths and http(s) URLs pass. Protocol-relative //host and javascript: don't, and those fall back to plain text. BlogProse renders the blocks as ordinary React elements, so the article body gets React's normal escaping and never goes through dangerouslySetInnerHTML.

4. The same parse, as structured data

Because the content is already typed blocks, structured data doesn't need a second pass over the raw text. faqsFromSections collects question headings (section headings or h3s ending in ?) and pairs each with the first paragraph under it. Answers under 40 characters are skipped, long ones are capped at 500, and fewer than two questions means no FAQ at all. howToFromSections looks for numbered h3 steps followed by a paragraph, and keeps the section with the most steps if it has at least three.

buildBlogGraph always emits a BlogPosting and a BreadcrumbList, and adds FAQPage and HowTo when those helpers find something. The page writes the graph into a script tag:

export function jsonLd(data: unknown): string {
  return JSON.stringify(data).replace(/</g, "\\u003c");
}

// ...
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(graph) }} />
Enter fullscreen mode Exit fullscreen mode

JSON.stringify doesn't escape <, so a </script> in a title or answer would close the tag early. \u003c parses back to the same JSON.

One caveat before you invest in FAQ and HowTo markup: Google changed both in 2023. FAQ rich results now only show for "well-known, authoritative government and health websites", and HowTo rich results were deprecated. Google also says unused structured data "does not cause problems". Ours comes out of a parse the page already does, so it's cheap to keep, but don't expect it to change how your results look.

5. Titles and descriptions from the same fields

Our post titles run long, and the root layout has a "%s | MotionLab" title template. seoTitle builds the <title> from the title's first clause:

export function primaryPhrase(title: string): string {
  const cut = title.split(/:\s|\s\|\s|\s[–—]\s/)[0].trim();
  return cut.replace(/\s*\([^)]*\)\s*/g, " ").replace(/\s+/g, " ").trim();
}

// ...
export function seoTitle(title: string): string {
  let phrase = primaryPhrase(title);
  if (phrase.length < 18) {
    phrase = title.replace(/\s*\([^)]*\)\s*/g, " ").replace(/\s+/g, " ").trim();
  }
  if (phrase.length + BRAND.length <= 60) return phrase + BRAND;
  if (phrase.length <= 60) return phrase;
  return trimAtWord(phrase, 60 - BRAND.length) + BRAND;
}
Enter fullscreen mode Exit fullscreen mode

"Power Training and Mobility: Why the Two Go Together (And How to Balance Both)" becomes "Power Training and Mobility | MotionLab". When it has to truncate, trimAtWord cuts at a space and drops trailing words like "to" and "the". generateMetadata passes the result as title: { absolute: title }, which skips the layout template so the brand doesn't appear twice.

metaDescription works the same way for descriptions: keep it if it fits in 160 characters, otherwise cut at the last sentence end after character 100, then at a clause break, and only then at a word.

6. Getting Medium's importer to keep the images

Medium's help center says the import tool hands the URL to a third-party service that returns the page's contents "to the best of their ability". Our images needed two fixes.

Fix one: absolute URLs, plain <img>. next/image renders a relative /_next/image?url=... optimizer URL, and our content uses root-relative paths like /images/blog/.... Both only work if the reader resolves them against our domain, and the optimizer URL also depends on our image endpoint. Now every image source goes through one helper:

export const SITE_URL = "https://motion-lab.io";

// Blog images must render with absolute URLs so off-site importers
// (Medium's medium.com/p/import, RSS readers, scrapers) can fetch them.
// Relative paths resolve the same way a browser would on /blog/<slug>.
export function absoluteUrl(src: string): string {
  try {
    return new URL(src, `${SITE_URL}/blog/`).toString();
  } catch {
    return src;
  }
}
Enter fullscreen mode Exit fullscreen mode

With /blog/ as the base, /images/a.jpg resolves to https://motion-lab.io/images/a.jpg and a bare img.jpg to /blog/img.jpg, as a browser would on /blog/<slug>. The same helper feeds og:image, the Twitter card and the JSON-LD image.

Fix two: no loading="lazy" on body images. The first fix used a plain <img loading="lazy">. Two days later we took the attribute off body images:

<figure key={index} className="my-8">
  <div className="relative w-full aspect-[3/2] rounded-2xl overflow-hidden border border-ml-border">
    {/* Plain <img> with an absolute src (not next/image's /_next/image URL)
        so Medium imports and other off-site readers can fetch it.
        No loading="lazy": Medium's importer drops lazy images. */}
    {/* eslint-disable-next-line @next/next/no-img-element */}
    <img
      src={absoluteUrl(block.src)}
      alt={block.alt}
      decoding="async"
      className="absolute inset-0 h-full w-full object-cover"
    />
  </div>
  {/* ... */}
</figure>
Enter fullscreen mode Exit fullscreen mode

The hero image gets the same treatment, with fetchPriority="high" and no loading attribute. The trade-off is real: blog pages no longer get next/image resizing or the AVIF/WebP formats our next.config.ts enables for the optimizer. Browsers get the original files, which in our posts are almost all JPEGs.

7. Related reading without a database

The links at the bottom of a post come from POSTS too. relatedReading sorts the slugs in the post's category alphabetically and walks outward from the current one, alternating next and previous, until it has five. Then it adds up to two guides whose keywords appear in the slug or title. It's deterministic: the same build always produces the same links.

Wrapping up

  • Check that your content is in the server HTML before tuning anything else. A client-only wrapper around children can quietly take it out.
  • When one team writes all the content, a small grammar you own can be enough. Keep the rules next to the parser.
  • Parse once into typed blocks, then derive the page, the JSON-LD and the metadata from them.
  • Escape < in inline JSON-LD.
  • If other services import your pages, give them absolute image URLs and plain <img> tags, and watch out for loading="lazy".

You can see the output on the MotionLab blog: view source on any post for the server-rendered HTML and the JSON-LD.

Do you hand-roll formatting like this, or reach for MDX or remark? And if you syndicate to Medium, what else have you found its importer drops? Tell us in the comments.

This post was drafted with AI assistance and reviewed by the MotionLab team.

Top comments (0)