or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

client-rendering.mdform-actions.mdindex.mdresource-hints.mdserver-rendering.mdstatic-generation.md
tile.json

static-generation.mddocs/

Static Site Generation

APIs for prerendering React components to static HTML with support for postponed content, enabling hybrid static and dynamic rendering strategies.

Runtime Selection

React DOM provides runtime-specific static generation implementations:

  • Node.js: react-dom/static or react-dom/static.node
  • Browser: react-dom/static.browser
  • Edge Runtimes: react-dom/static.edge

Capabilities

prerender (Web Streams)

Prerenders React elements to a ReadableStream with optional postponed state for later completion.

/**
 * Prerender to static HTML with postponed content support
 * @param children - React elements to prerender
 * @param options - Configuration options
 * @returns Promise with prelude stream and postponed state
 */
function prerender(
  children: ReactNode,
  options?: PrerenderOptions
): Promise<{
  prelude: ReadableStream;
  postponed: PostponedState | null;
}>;

interface PrerenderOptions {
  /** Prefix for IDs generated by useId */
  identifierPrefix?: string;
  /** Namespace URI for SVG or MathML content */
  namespaceURI?: string;
  /** Inline script content to inject before React code */
  bootstrapScriptContent?: string;
  /** External script URLs to load */
  bootstrapScripts?: Array<string | BootstrapScriptDescriptor>;
  /** ES module URLs to load */
  bootstrapModules?: Array<string | BootstrapScriptDescriptor>;
  /** Target chunk size for progressive streaming (bytes) */
  progressiveChunkSize?: number;
  /** AbortSignal to cancel prerendering */
  signal?: AbortSignal;
  /** Error handler called for all errors */
  onError?: (error: Error, errorInfo: ErrorInfo) => string | void;
  /** Called when content is postponed */
  onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void;
  /** External runtime script for React */
  unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor;
  /** Import map for module resolution */
  importMap?: ImportMap;
  /** Called with headers that should be set */
  onHeaders?: (headers: Headers) => void;
  /** Maximum length for header values */
  maxHeadersLength?: number;
}

interface BootstrapScriptDescriptor {
  src: string;
  integrity?: string;
  crossOrigin?: 'anonymous' | 'use-credentials';
}

type PostponedState = OpaquePostponedState; // Opaque postponed rendering state

interface ErrorInfo {
  componentStack?: string;
}

interface PostponeInfo {
  componentStack?: string;
}

Usage Examples:

import { prerender } from 'react-dom/static';

// Basic prerendering
const { prelude, postponed } = await prerender(<App />);

// Save to file
const file = await Deno.open('index.html', { write: true, create: true });
await prelude.pipeTo(file.writable);

// Check if anything was postponed
if (postponed) {
  console.log('Some content was postponed');
  // Save postponed state for later
  await Deno.writeTextFile('postponed.json', JSON.stringify(postponed));
}

// With configuration
const { prelude, postponed } = await prerender(<App />, {
  bootstrapModules: ['/app.js'],
  signal: AbortSignal.timeout(30000),
  onError(error, errorInfo) {
    console.error('Prerender error:', error);
    console.error('Component stack:', errorInfo.componentStack);
  },
  onPostpone(reason, postponeInfo) {
    console.log('Postponed:', reason);
    console.log('At:', postponeInfo.componentStack);
  }
});

// Edge function for static generation
export default {
  async scheduled(event, env) {
    const { prelude, postponed } = await prerender(<BlogIndex />, {
      bootstrapModules: ['/blog.js']
    });

    // Store static HTML
    await env.STORAGE.put('blog/index.html', prelude);

    // Store postponed state if needed
    if (postponed) {
      await env.STORAGE.put('blog/postponed.json', JSON.stringify(postponed));
    }
  }
};

Key Features:

  • Postponed Content: Components can postpone rendering to request time
  • Static Shell: Generate static HTML for SEO and performance
  • Hybrid Rendering: Combine static and dynamic content
  • Zero Runtime: Static HTML doesn't need React on the client (unless hydrating)

prerenderToNodeStream (Node.js)

Node.js-specific prerendering to a Readable stream.

/**
 * Prerender to Node.js Readable stream
 * @param children - React elements to prerender
 * @param options - Configuration options
 * @returns Promise with prelude stream and postponed state
 */
function prerenderToNodeStream(
  children: ReactNode,
  options?: PrerenderOptions
): Promise<{
  prelude: Readable;
  postponed: PostponedState | null;
}>;

type Readable = NodeJS.ReadableStream;

Usage Examples:

import { prerenderToNodeStream } from 'react-dom/static';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';

// Prerender and save to file
const { prelude, postponed } = await prerenderToNodeStream(<App />);

await pipeline(
  prelude,
  createWriteStream('dist/index.html')
);

if (postponed) {
  await writeFile(
    'dist/postponed.json',
    JSON.stringify(postponed)
  );
}

// Build-time static generation
import { prerenderToNodeStream } from 'react-dom/static';
import { writeFile } from 'fs/promises';

async function buildStaticSite() {
  const pages = [
    { path: 'index.html', component: <HomePage /> },
    { path: 'about.html', component: <AboutPage /> },
    { path: 'blog/index.html', component: <BlogIndex /> }
  ];

  for (const page of pages) {
    const { prelude, postponed } = await prerenderToNodeStream(
      page.component,
      {
        bootstrapModules: ['/client.js'],
        identifierPrefix: page.path + '-'
      }
    );

    // Save HTML
    const chunks = [];
    for await (const chunk of prelude) {
      chunks.push(chunk);
    }
    await writeFile(`dist/${page.path}`, Buffer.concat(chunks));

    // Save postponed state
    if (postponed) {
      await writeFile(
        `dist/${page.path}.postponed`,
        JSON.stringify(postponed)
      );
    }
  }
}

buildStaticSite();

resumeAndPrerender (Web Streams)

Resumes and prerenders from previously postponed state.

/**
 * Resume and prerender from postponed state
 * @param children - React elements to render
 * @param postponedState - State from previous postponed render
 * @param options - Configuration options
 * @returns Promise with prelude stream and new postponed state
 */
function resumeAndPrerender(
  children: ReactNode,
  postponedState: PostponedState,
  options?: PrerenderOptions
): Promise<{
  prelude: ReadableStream;
  postponed: PostponedState | null;
}>;

Usage Example:

import { resumeAndPrerender } from 'react-dom/static';

// Load postponed state from build time
const postponedState = await loadPostponedState();

export default {
  async fetch(request) {
    // Complete the postponed rendering at request time
    const { prelude, postponed } = await resumeAndPrerender(
      <App url={request.url} />,
      postponedState,
      {
        bootstrapModules: ['/app.js'],
        onError(error) {
          console.error('Resume error:', error);
        }
      }
    );

    // Should be no more postponed content
    if (postponed) {
      console.warn('Still have postponed content');
    }

    return new Response(prelude, {
      headers: { 'Content-Type': 'text/html' }
    });
  }
};

resumeAndPrerenderToNodeStream (Node.js)

Node.js-specific resume and prerender from postponed state.

/**
 * Resume and prerender to Node.js stream from postponed state
 * @param children - React elements to render
 * @param postponedState - State from previous postponed render
 * @param options - Configuration options
 * @returns Promise with prelude stream and new postponed state
 */
function resumeAndPrerenderToNodeStream(
  children: ReactNode,
  postponedState: PostponedState,
  options?: PrerenderOptions
): Promise<{
  prelude: Readable;
  postponed: PostponedState | null;
}>;

Usage Example:

import { resumeAndPrerenderToNodeStream } from 'react-dom/static';
import { readFile } from 'fs/promises';

// Request handler completing postponed render
export async function handler(req, res) {
  // Load postponed state from build
  const postponedState = JSON.parse(
    await readFile('dist/postponed.json', 'utf-8')
  );

  const { prelude, postponed } = await resumeAndPrerenderToNodeStream(
    <App url={req.url} />,
    postponedState,
    {
      bootstrapModules: ['/app.js']
    }
  );

  res.setHeader('Content-Type', 'text/html');
  prelude.pipe(res);
}

Postponing Content

Using postpone()

import { postpone } from 'react';

function DynamicContent({ userId }) {
  if (typeof window === 'undefined') {
    // Postpone during static generation
    postpone('User-specific content');
  }

  // This runs at request time or on client
  const user = useUser(userId);
  return <div>{user.name}</div>;
}

function BlogPost() {
  return (
    <article>
      <h1>Static Title</h1>
      <p>Static content that gets prerendered...</p>

      {/* This will be postponed */}
      <DynamicContent userId={currentUserId} />
    </article>
  );
}

Workflow:

  1. Build time: Call prerender() - static content is rendered, dynamic content is postponed
  2. Save: Store both the static HTML and postponed state
  3. Request time: Call resumeAndPrerender() with the postponed state - dynamic content is rendered
  4. Serve: Send complete HTML to client

Suspense with Postpone

import { Suspense, postpone } from 'react';

function UserProfile({ userId }) {
  if (typeof window === 'undefined') {
    postpone('User profile');
  }

  const user = useUser(userId);
  return <ProfileCard user={user} />;
}

function App() {
  return (
    <div>
      <Header /> {/* Static */}

      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile userId={currentUserId} />
      </Suspense>

      <Footer /> {/* Static */}
    </div>
  );
}

// Build time:
// - Header and Footer are in static HTML
// - UserProfile is in postponed state with Suspense fallback

// Request time:
// - UserProfile completes
// - Client hydrates seamlessly

Static Generation Patterns

Pure Static Site

No postponed content - everything is static.

const { prelude, postponed } = await prerenderToNodeStream(<StaticPage />);

console.log(postponed); // null

// Save to CDN
await saveToS3('page.html', prelude);

Hybrid Static + Dynamic

Static shell with dynamic content.

// Build time
const { prelude, postponed } = await prerenderToNodeStream(
  <BlogPost id={postId} />
);

await saveStatic('blog/' + postId + '.html', prelude);
await savePostponed('blog/' + postId + '.postponed', postponed);

// Request time
const postponedState = await loadPostponed('blog/' + postId + '.postponed');

const { prelude } = await resumeAndPrerenderToNodeStream(
  <BlogPost id={postId} currentUser={req.user} />,
  postponedState
);

res.send(prelude);

Progressive Static Generation

Build popular pages statically, others on-demand.

// Build popular pages
for (const id of popularPostIds) {
  const { prelude } = await prerenderToNodeStream(<Post id={id} />);
  await saveToCDN(`posts/${id}.html`, prelude);
}

// On-demand for other pages
export async function handler(req) {
  const id = req.params.id;

  if (await existsInCDN(`posts/${id}.html`)) {
    return serveFromCDN(`posts/${id}.html`);
  }

  // Generate on first request
  const { prelude } = await prerenderToNodeStream(<Post id={id} />);

  // Cache for future requests
  await saveToCDN(`posts/${id}.html`, prelude);

  return new Response(prelude);
}

Incremental Static Regeneration (ISR)

Regenerate static pages periodically.

const cache = new Map();

export async function handler(req) {
  const path = req.url.pathname;
  const cached = cache.get(path);

  // Serve from cache if fresh
  if (cached && Date.now() - cached.timestamp < 60000) {
    return new Response(cached.html);
  }

  // Regenerate
  const { prelude } = await prerenderToNodeStream(<App path={path} />);

  const chunks = [];
  for await (const chunk of prelude) {
    chunks.push(chunk);
  }
  const html = Buffer.concat(chunks).toString();

  // Update cache
  cache.set(path, { html, timestamp: Date.now() });

  return new Response(html, {
    headers: { 'Content-Type': 'text/html' }
  });
}

Error Handling

Build-Time Errors

const { prelude, postponed } = await prerenderToNodeStream(<App />, {
  onError(error, errorInfo) {
    // Log errors during prerendering
    console.error('Prerender error:', error);
    console.error('Stack:', errorInfo.componentStack);

    // Optionally include error HTML
    return `<!-- Error: ${error.message} -->`;
  }
});

Postpone Errors

const { prelude, postponed } = await prerenderToNodeStream(<App />, {
  onPostpone(reason, postponeInfo) {
    console.log('Content postponed:', reason);
    console.log('Location:', postponeInfo.componentStack);

    // Track what's being postponed
    metrics.increment('postponed_content', {
      reason,
      location: postponeInfo.componentStack
    });
  }
});

Resume Errors

try {
  const { prelude, postponed } = await resumeAndPrerender(
    <App />,
    postponedState,
    {
      signal: AbortSignal.timeout(5000),
      onError(error) {
        console.error('Resume error:', error);
      }
    }
  );

  if (postponed) {
    console.warn('Still have postponed content after resume');
  }

  return new Response(prelude);
} catch (error) {
  console.error('Resume failed:', error);
  return new Response('Error', { status: 500 });
}

Performance Optimization

Minimize Postponed Content

// ❌ Bad: Postponing everything
function Page() {
  if (typeof window === 'undefined') {
    postpone('entire page');
  }
  return <App />;
}

// ✅ Good: Only postpone what's necessary
function Page() {
  return (
    <div>
      <StaticHeader />
      <StaticContent />
      <DynamicUserSection /> {/* Only this postpones */}
      <StaticFooter />
    </div>
  );
}

Parallel Generation

// Generate multiple pages in parallel
await Promise.all(
  pages.map(async (page) => {
    const { prelude, postponed } = await prerenderToNodeStream(page.component);
    await savePage(page.path, prelude, postponed);
  })
);

Streaming to Storage

import { S3 } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';

const { prelude } = await prerenderToNodeStream(<App />);

const upload = new Upload({
  client: s3,
  params: {
    Bucket: 'static-site',
    Key: 'index.html',
    Body: prelude,
    ContentType: 'text/html'
  }
});

await upload.done();

Best Practices

  1. Maximize Static Content: Keep as much content static as possible for better performance
  2. Strategic Postponing: Only postpone truly dynamic/personalized content
  3. Cache Postponed State: Store and reuse postponed state when possible
  4. Monitor Generation Time: Track prerender duration and optimize slow pages
  5. Handle Errors Gracefully: Configure onError to prevent build failures from component errors
  6. Use AbortSignal: Set timeouts to prevent hanging builds
  7. Validate Postponed State: Check if postponed is null after resume to ensure completion
  8. Compress Output: Enable compression when saving static HTML
  9. Parallel Builds: Generate multiple pages concurrently when possible
  10. Incremental Builds: Only regenerate pages that changed

Comparison with SSR

FeatureStatic GenerationServer Rendering
WhenBuild timeRequest time
SpeedInstant (pre-built)Fast (streamed)
PersonalizationLimited (with postpone)Full
Server LoadNone (CDN)Per request
SEOExcellentExcellent
FreshnessStale (until rebuild)Always fresh
CostLow (static hosting)Higher (server)

Use Cases

Documentation Sites

// Build entire docs site statically
const docs = await loadAllDocs();

for (const doc of docs) {
  const { prelude } = await prerenderToNodeStream(
    <DocPage content={doc.content} />
  );
  await saveToStatic(`docs/${doc.slug}.html`, prelude);
}

E-commerce Product Pages

// Static product details, dynamic cart
function ProductPage({ productId }) {
  return (
    <div>
      <ProductDetails id={productId} /> {/* Static */}
      <Reviews id={productId} /> {/* Static */}
      <UserCart /> {/* Postponed */}
    </div>
  );
}

Blogs with Comments

// Static post content, dynamic comments
function BlogPost({ postId }) {
  return (
    <article>
      <PostContent id={postId} /> {/* Static */}
      <Comments postId={postId} /> {/* Postponed or client-only */}
    </article>
  );
}

Version

const version: string; // "19.2.0"

Types

ImportMap

interface ImportMap {
  imports?: Record<string, string>;
  scopes?: Record<string, Record<string, string>>;
}

Headers

interface Headers {
  [key: string]: string | string[];
}