Skip to main content

Rendering Process

The rendering process is where Scully transforms your Angular application routes into static HTML files. This is the core functionality that makes Scully a powerful static site generator.

Overview

For each handled route, Scully executes a rendering pipeline that:
  1. Optionally runs a preRender function
  2. Uses a render plugin (default: Puppeteer) to generate initial HTML
  3. Processes HTML through post-render plugins
  4. Writes the final output to the file system

The Render Pipeline

The complete rendering pipeline is orchestrated by the executePluginsForRoute function:
// From libs/scully/src/lib/renderPlugins/executePlugins.ts
const executePluginsForRoute = async (route: HandledRoute) => {
  // Collect all handlers for this route
  const handlers = [
    route.type, 
    ...(route.postRenderers || scullyConfig.defaultPostRenderers)
  ].filter(Boolean);
  
  // Execute preRender if configured
  const preRender = route.config && route.config.preRenderer;
  if (preRender) {
    try {
      const prResult = await preRender(route);
      if (prResult === false) {
        logWarn(`The prerender function stopped rendering for "${route.route}"`);
        return '';
      }
    } catch (e) {
      logError(`The prerender function did error during rendering`);
      return '';
    }
  }
  
  // Generate initial HTML using render plugin
  const InitialHTML = await (
    route.renderPlugin 
      ? findPlugin(route.renderPlugin) 
      : findPlugin(routeRenderer)
  )(route);

  // Split out jsDom vs string renderers
  const { jsDomRenders, renders: stringRenders } = handlers.reduce(
    (result, plugin) => {
      const textHandler = findPlugin(plugin, 'postProcessByHtml', false);
      if (textHandler !== undefined) {
        result.renders.push({ plugin, handler: textHandler });
      }
      const jsDomHandler = findPlugin(plugin, 'postProcessByDom', false);
      if (jsDomHandler !== undefined) {
        result.jsDomRenders.push({ plugin, handler: jsDomHandler });
      }
      return result;
    },
    { jsDomRenders: [], renders: [] }
  );

  // Process through DOM plugins first
  let jsDomHtml: string;
  if (jsDomRenders.length > 0) {
    const startDom = findPlugin(toJSDOM)(InitialHTML);
    const endDom = await jsDomRenders.reduce(
      async (dom, { handler, plugin }) => {
        const d = await dom;
        try {
          return handler(d, route);
        } catch (e) {
          logError(`Error with plugin "${plugin}"`);
          return findPlugin(toJSDOM)(InitialHTML);
        }
      }, 
      startDom
    );
    jsDomHtml = await findPlugin(fromJSDOM)(endDom);
  }

  // Process through HTML string plugins
  return stringRenders.reduce(
    async (updatedHTML, { handler, plugin }) => {
      const html = await updatedHTML;
      try {
        return await handler(html, route);
      } catch (e) {
        logError(`Error with plugin "${plugin}"`);
      }
      return html;
    }, 
    Promise.resolve(jsDomHtml || InitialHTML)
  );
};

Pre-Render Functions

Before rendering begins, you can run a preRender function to conditionally control rendering:
export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './blog'
      },
      preRenderer: async (route: HandledRoute) => {
        // Skip unpublished posts
        if (route.data?.published === false) {
          console.log(`Skipping unpublished post: ${route.route}`);
          return false;
        }
        
        // Add metadata from external API
        const analytics = await fetchAnalytics(route.route);
        route.data = { ...route.data, analytics };
        
        return true;
      }
    }
  }
};
When preRender returns false, the route is skipped but still appears in scully.routes.json with its metadata.

Use Cases for Pre-Render

Skip routes based on data conditions:
preRenderer: async (route) => {
  // Only render published content
  if (route.data?.status !== 'published') {
    return false;
  }
  // Only render future-dated posts if in preview mode
  if (new Date(route.data?.publishDate) > new Date() && !process.env.PREVIEW) {
    return false;
  }
  return true;
}
Fetch additional data before rendering:
preRenderer: async (route) => {
  // Fetch author details
  const author = await fetchAuthor(route.data?.authorId);
  route.data = { ...route.data, author };
  
  // Fetch related posts
  const related = await fetchRelatedPosts(route.route);
  route.injectToPage = { ...route.injectToPage, related };
  
  return true;
}
Modify plugins based on route data:
preRenderer: async (route) => {
  // Use different plugins for different content types
  if (route.data?.format === 'amp') {
    route.postRenderers = ['ampOptimizer', 'minifyHtml'];
  } else {
    route.postRenderers = ['seoOptimizer', 'minifyHtml', 'criticalCss'];
  }
  return true;
}

Render Plugins

Render plugins are responsible for generating the initial HTML. The default is the Puppeteer render plugin, but you can use or create alternatives.

Puppeteer Render Plugin (Default)

The Puppeteer plugin:
  1. Launches a headless Chrome browser
  2. Navigates to your Angular application at the specified route
  3. Waits for the Angular app to signal it’s ready
  4. Extracts the fully-rendered HTML
// Simplified view of how Puppeteer rendering works
async function puppeteerRender(route: HandledRoute): Promise<string> {
  const page = await browser.newPage();
  
  // Navigate to the route
  await page.goto(
    route.rawRoute || `http://localhost:${port}${route.route}`,
    { waitUntil: 'networkidle0' }
  );
  
  // Wait for Angular to be ready
  await page.waitForFunction('window.scullyReady === true');
  
  // Extract HTML
  const html = await page.content();
  
  await page.close();
  return html;
}
Your Angular application must set window.scullyReady = true when it’s ready to be rendered. The @scullyio/ng-lib package handles this automatically.

Custom Render Plugins

You can specify a different render plugin for specific routes:
export const config: ScullyConfig = {
  routes: {
    '/api-docs/:page': {
      type: 'json',
      renderPlugin: 'customApiRenderer'
    }
  }
};
Create a custom render plugin:
import { registerPlugin } from '@scullyio/scully';

const customApiRenderer = async (route: HandledRoute): Promise<string> => {
  // Custom rendering logic
  const apiData = await fetchApiData(route.route);
  const template = await loadTemplate('api-template.html');
  return renderTemplate(template, apiData);
};

registerPlugin('render', 'customApiRenderer', customApiRenderer);

Post-Render Plugins

After initial rendering, post-render plugins process the HTML. There are two types:

HTML String Plugins

These receive HTML as a string and return modified HTML:
import { registerPlugin } from '@scullyio/scully';
import { HandledRoute } from '@scullyio/scully';

const addCopyright = async (html: string, route: HandledRoute): Promise<string> => {
  const year = new Date().getFullYear();
  const copyright = `<!-- Copyright ${year} MyCompany -->`;
  return html.replace('</body>', `${copyright}</body>`);
};

registerPlugin('postProcessByHtml', 'addCopyright', addCopyright);

JSDOM Plugins

These receive a JSDOM instance for DOM manipulation:
import { registerPlugin } from '@scullyio/scully';
import { JSDOM } from 'jsdom';
import { HandledRoute } from '@scullyio/scully';

const addMetaTags = async (dom: JSDOM, route: HandledRoute): Promise<JSDOM> => {
  const { window } = dom;
  const { document } = window;
  
  // Add Open Graph tags
  const ogTitle = document.createElement('meta');
  ogTitle.setAttribute('property', 'og:title');
  ogTitle.setAttribute('content', route.data?.title || 'Default Title');
  document.head.appendChild(ogTitle);
  
  const ogDescription = document.createElement('meta');
  ogDescription.setAttribute('property', 'og:description');
  ogDescription.setAttribute('content', route.data?.description || '');
  document.head.appendChild(ogDescription);
  
  return dom;
};

registerPlugin('postProcessByDom', 'addMetaTags', addMetaTags);
JSDOM plugins run before HTML string plugins, allowing you to make DOM manipulations before text-based transformations.

Plugin Execution Order

Post-render plugins execute in a specific order:
  1. JSDOM plugins in the order specified
  2. HTML string plugins in the order specified
// Configure plugin order
export const config: ScullyConfig = {
  defaultPostRenderers: [
    'seoHrefOptimisation',  // JSDOM plugin
    'addMetaTags',          // JSDOM plugin
    'minifyHtml',           // HTML string plugin
    'addCopyright'          // HTML string plugin
  ]
};
You can override the default order for specific routes:
export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: { folder: './blog' },
      postRenderers: [
        'contentRender',      // Render markdown content
        'addTableOfContents', // Custom plugin
        'syntaxHighlight',    // Custom plugin
        'minifyHtml'
      ]
    }
  }
};

Writing to Storage

After all plugins complete, the final HTML is written to disk:
// Route determines file location
const filePath = routeToFilePath(route.route);
// /blog/my-post -> dist/static/blog/my-post/index.html

fs.writeFileSync(filePath, html);

TransferState Extraction

If the HTML contains Angular TransferState data, Scully extracts it:
<!-- In the rendered HTML -->
<script id="ScullyIO-transfer-state">
  window['ScullyIO'] = { /* data */ };
</script>
This data is saved as data.json alongside index.html:
dist/static/blog/my-post/
├── index.html
└── data.json
TransferState allows your Angular app to rehydrate with the same data used during pre-rendering, avoiding duplicate API calls.

Parallel Rendering

Scully renders multiple routes in parallel to maximize performance:
const cpuCores = os.cpus().length;
const maxParallel = scullyConfig.maxRenderThreads || cpuCores;

await Promise.all(
  routes.map(route => 
    renderQueue.add(() => executePluginsForRoute(route))
  )
);
You can control parallelization:
export const config: ScullyConfig = {
  maxRenderThreads: 4, // Limit to 4 parallel renders
};
Setting maxRenderThreads too high can overwhelm your system. The default (number of CPU cores) is usually optimal.

Performance Optimization

Route Batching

For large sites, consider batching routes:
# Render only blog routes
npx scully --routeFilter "/blog/*"

# Render only docs routes  
npx scully --routeFilter "/docs/*"

Plugin Optimization

// Slow: Multiple DOM queries
const addLinks = async (dom: JSDOM) => {
  const links = dom.window.document.querySelectorAll('a');
  links.forEach(link => {
    link.setAttribute('target', '_blank');
  });
  return dom;
};

// Faster: Single pass
const addLinks = async (dom: JSDOM) => {
  const { document } = dom.window;
  const fragment = document.createDocumentFragment();
  // Batch DOM operations
  return dom;
};

Error Handling

Scully provides robust error handling during rendering:
try {
  return await handler(html, route);
} catch (e) {
  captureException(e);
  logError(
    `Error during content generation with plugin "${plugin}" for ${route.templateFile}. This handler is skipped.`
  );
}
// Continue with original HTML
return html;
When a plugin fails, Scully:
  • Logs the error with context
  • Continues with the HTML from before the failed plugin
  • Completes rendering of other routes
  • Does not halt the entire build process

Debugging Rendered Output

To debug the rendering process:
1

Enable verbose logging

npx scully --log all
2

Generate performance stats

npx scully --stats
Check scullyStats.json for plugin timings.
3

Test a single route

npx scully --routeFilter "/specific/route"
4

Inspect intermediate output

Add a debug plugin:
const debugOutput = async (html: string, route: HandledRoute) => {
  console.log(`Rendering: ${route.route}`);
  console.log(`HTML length: ${html.length}`);
  fs.writeFileSync(`debug-${route.route.replace(/\//g, '-')}.html`, html);
  return html;
};

registerPlugin('postProcessByHtml', 'debug', debugOutput);

Next Steps

Post-Render Plugins

Create custom post-render plugins

Render Plugins

Build alternative render plugins

Handled Routes

Understand route structure and metadata

Plugin System

Learn about all plugin types

Build docs developers (and LLMs) love