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:
Optionally runs a preRender function
Uses a render plugin (default: Puppeteer) to generate initial HTML
Processes HTML through post-render plugins
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 ;
}
Dynamic plugin configuration
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:
Launches a headless Chrome browser
Navigates to your Angular application at the specified route
Waits for the Angular app to signal it’s ready
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:
JSDOM plugins in the order specified
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 );
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.
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 ;
};
// Cache API responses across routes
const cache = new Map ();
const enrichWithData = async ( route : HandledRoute ) => {
const cacheKey = route . data ?. apiEndpoint ;
if ( ! cache . has ( cacheKey )) {
const data = await fetchData ( cacheKey );
cache . set ( cacheKey , data );
}
route . data . enriched = cache . get ( cacheKey );
return true ;
};
// Process independent operations in parallel
const optimizeAssets = async ( html : string ) => {
const [ optimizedImages , minifiedCss , minifiedJs ] = await Promise . all ([
optimizeImages ( html ),
minifyCss ( html ),
minifyJs ( html )
]);
return combineOptimizations ( html , {
optimizedImages ,
minifiedCss ,
minifiedJs
});
};
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:
Generate performance stats
Check scullyStats.json for plugin timings.
Test a single route
npx scully --routeFilter "/specific/route"
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