Renderer & HTML Output

The renderer is the heart of static.bio's "static" claim. It's a pure function that takes profile data and returns HTML—no database, no I/O, no environment reads.

Renderer Shape

The renderer is a standalone package (@static-bio/render) with zero framework dependencies:

export function renderProfilePage(input: RenderInput): RenderResult;

type RenderInput = {
  profile: {
    id: string;
    username: string;
    displayName: string;
    bio?: string;
    avatarUrl?: string;
    themeId: string;
    plan: "FREE" | "LIFETIME";
    analyticsEnabled: boolean;
  };
  links: Array<{
    id: string;
    label: string;
    url: string;
    enabled: boolean;
    order: number;
  }>;
  options: {
    environment: "production" | "preview" | "export";
    baseUrl: string;
    trackingBaseUrl: string;
    showBrandingFooter: boolean;
  };
};

type RenderResult = {
  html: string;  // Full HTML document
  meta: {
    version: string;      // "v1"
    contentHash: string;   // SHA-256 hash of HTML
    generatedAt: string;   // ISO timestamp
  };
};

Constraints:

  • Pure: No reading from ENV, no network calls
  • Deterministic: Same input → same HTML + contentHash
  • Side-effect free: Caller handles writing to disk / HTTP / caching

HTML & CSS Strategy

Inline CSS

All CSS is inlined in a <style> tag within the HTML document. There are no external stylesheets, no <link> tags pointing to CSS files.

  • CSS comes from packages/render/src/css.ts (PUBLIC_CSS constant)
  • Dashboard CSS (Tailwind) is completely separate—never loaded on public pages
  • Size: ~2KB of CSS inlined (well under 15KB budget)

System Font Stack

We use only system fonts:

font-family: system-ui, -apple-system, BlinkMacSystemFont, 
  "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

This eliminates font downloads (typically 50-200KB, 100-300ms) and ensures instant rendering.

Minimal HTML Structure

HTML structure is hand-written with minimal markup:

  • Simple semantic HTML (<main>, <nav>, <section>)
  • No unnecessary wrapper divs
  • No framework-generated markup overhead

Result: HTML structure is ~1-2KB before gzip, ~1.88KB gzipped total (HTML + CSS).

Escaping & Safety

User Input Escaping

All user-controlled content is HTML-escaped:

  • displayName, bio, link.label are escaped
  • URLs are validated and sanitized before rendering
  • Avatar URLs are user-controlled but rendered in <img src> (browser handles escaping)

XSS Prevention

XSS is prevented through multiple layers:

  • No JavaScript: Even if HTML injection occurs, no scripts can execute
  • Careful escaping: All user input is HTML-escaped before rendering
  • Content Security Policy: Can be added via headers (future enhancement)

Versioning

The renderer is versioned (v1, v2, etc.) to allow gradual migration:

  • Each renderer version has a stable API
  • New versions can be added without breaking old profiles
  • Profiles can be migrated from v1 → v2 at our pace
  • Version is included in meta.version and X-Render-Version header

Future-Proofing

This design makes static export trivial:

// Future export script
const profiles = await getAllProfiles();
for (const profile of profiles) {
  const { html } = renderProfilePage({
    profile,
    links: profile.links,
    options: { environment: "export", ... }
  });
  await writeFile(`out/${profile.username}/index.html`, html);
}

The same renderer function that runs at request time can be called at build time to generate static HTML files. This is the "framework apocalypse" escape hatch: if we need to move away from Next.js, we can export all profiles as static files and host them anywhere.