Skip to content
Hooney
한국어
Products

Modules

seo

An SEO engine that fills page metadata and structured data through dynamic variables.

4Apps that define page SEO with this engine: the homepage, blog, Journey, and ROUND

Period
2026.05 ~ Present
Organization
Firstage
Role
Design and implementation (solo)
Runs on
Frontend
Key features
A page passes only its values, and the same templates swap each variable for its value at render time to produce the metadata and JSON-LD.
Work and results
  • Each page builds a render context grouped into namespaces such as site, page, and hreflang, and templates point to paths like '{{page.title}}'
  • A value holding a single variable keeps its original type, so '{{page.imageWidth}}' goes out as a number, not a string
  • Metadata and JSON-LD have separate renderers: the same variable goes into metadata as is and into JSON-LD with JSON-safe escaping
  • The Firstage homepage, blog, Journey, and ROUND define page SEO with this engine
Tech stack
  • TypeScript
  • Mustache
  • Zod
  • schema.org

Background

Firstage publishes pages from several apps in Korean, English, and Japanese. Each page needs a title, description, lead image, per-language URLs, and schema.org structured data. When every app assembles its own metadata object, the same rules end up scattered, and fixing one app leaves the others on the old rules. I built this engine so the shape of the metadata lives in one set of templates and only the values that change from page to page come in as dynamic variables.

How the dynamic variables work

  1. Each page is a defineSeoModule unit. Its buildContext takes input such as the place, the language, and the site configuration, and builds a render context grouped into namespaces such as site, page, and hreflang.

  2. Templates are data, not code. They keep the shape of the metadata and put a context path in each value slot as a variable. Here is part of the template the Firstage homepage uses.

    export const HOMEPAGE_SEO_METADATA_TEMPLATE = {
      title: '{{page.title}}',
      alternates: {
        canonical: '{{page.canonical}}',
        languages: {
          'x-default': '{{hreflang.xDefault}}',
          en: '{{hreflang.en}}',
          ja: '{{hreflang.ja}}',
          ko: '{{hreflang.ko}}',
        },
      },
      openGraph: {
        siteName: '{{site.name}}',
        images: [{ url: '{{page.imageUrl}}', width: '{{page.imageWidth}}', height: '{{page.imageHeight}}' }],
      },
    };
  3. When the server renders a page, Mustache replaces each variable with its value from the context. A value holding a single variable keeps the original type of its context value, so a number such as the image width still goes out as a number from '{{page.imageWidth}}'. Mixed with text, as in '{{page.imageWidth}}px', it becomes a string.

  4. The same variable goes through a different renderer for each output. Next.js escapes metadata itself, so the metadata renderer does not escape. The JSON-LD renderer escapes in a JSON-safe way, which makes {{var}} and {{{var}}} equally safe. No global setting changes, so concurrent renders never mix their settings.

  5. In development and tests, rendered output is checked again against Zod schemas, and output that does not match fails on the spot.

Decisions and implementation

  • Builders cover 11 schema.org types, including article, breadcrumb, organization, product, service, and website, and every one emits the same @context.
  • Because templates are data, templates edited by an admin could later be stored and replace the defaults without changing the calling code. For that, I defined the variable catalog types an admin variable picker would use: descriptions, type-aware inputs, preview samples, and deprecation notes.

Current status

On the Firstage homepage, the place, course, magazine, and guide pages use this engine for page SEO, as do the Firstage blog, Journey, and the ROUND home page. There is no admin screen for editing templates and no variable picker yet.