Skip to main content

Handled Routes

A handled route is a fully-resolved route with all the metadata and configuration needed for Scully to render it into a static HTML file. Handled routes are created by router plugins that transform unhandled routes with parameters into concrete, renderable paths.

From Unhandled to Handled

When Scully encounters an unhandled route with parameters, it passes the route through a router plugin. The plugin expands the parameterized route into one or more concrete routes.
// Discovered by route traversal
const unhandledRoute = '/user/:id';
This route has a parameter :id and cannot be rendered without knowing what values to use.
All unhandled routes pass through a router plugin, even simple routes without parameters. Routes without a custom plugin configuration use the default router plugin, which converts them directly to handled routes.

HandledRoute Interface

The HandledRoute interface defines the structure of a handled route:
// From libs/scully/src/lib/routerPlugins/handledRoute.interface.ts
export interface HandledRoute {
  /** the string as used in the Scully config */
  usedConfigRoute?: string;
  
  /** the _complete_ route */
  route: string;
  
  /** the raw route, will be used by puppeteer over route.route */
  rawRoute?: string;
  
  /** String, must be an existing plugin name. mandatory */
  type?: string;
  
  /** the relevant part of the scully-config  */
  config?: RouteConfig;
  
  /** variables exposed to angular _while rendering only!_ */
  exposeToPage?: {
    manualIdle?: boolean;
    transferState?: Serializable;
    [key: string]: Serializable;
  };
  
  /** data will be injected into the static page */
  injectToPage?: {
    [key: string]: Serializable;
  };
  
  /** an array with render plugin names that will be executed */
  postRenderers?: (string | symbol)[];
  
  /** the path to the file for a content file */
  templateFile?: string;
  
  /** optional title, if data holds a title, that will be used instead */
  title?: string;
  
  /** additional data that will end up in scully.routes.json */
  data?: RouteData;
  
  /** Plugin to use for rendering */
  renderPlugin?: string | symbol;
}

Key Properties Explained

The complete route path that will be rendered.
const route: HandledRoute = {
  route: '/blog/my-first-post'
};
This becomes the URL path: yourdomain.com/blog/my-first-post
An alternative URL for Puppeteer to navigate to when rendering. Useful for routes that need query parameters or hash fragments.
const route: HandledRoute = {
  route: '/search',
  rawRoute: 'http://localhost:1668/search?q=scully&page=1'
};
The file will be saved at /search/index.html but rendered from the rawRoute URL.
The type of route, which determines which router plugin created it and may affect post-processing.
const route: HandledRoute = {
  route: '/blog/post',
  type: 'contentFolder' // or 'json', 'default', etc.
};
Common types: default, contentFolder, json, ignored
Configuration data from your scully.config.ts for this route.
const route: HandledRoute = {
  route: '/blog/post',
  type: 'contentFolder',
  config: {
    manualIdleCheck: true,
    preRenderer: async (route) => { /* ... */ }
  }
};
Arbitrary data that appears in scully.routes.json and can be accessed in your Angular app.
const route: HandledRoute = {
  route: '/blog/scully-basics',
  data: {
    title: 'Scully Basics',
    author: 'John Doe',
    published: true,
    tags: ['angular', 'ssg'],
    publishDate: '2024-01-15'
  }
};
This data is accessible via the ScullyRoutesService in Angular.
Data injected into the static HTML page as a global variable.
const route: HandledRoute = {
  route: '/blog/post',
  injectToPage: {
    buildTime: new Date().toISOString(),
    version: '1.0.0',
    config: { feature: true }
  }
};
Accessible in the browser as window.ScullyIO.injectToPage
Variables exposed to Angular during rendering only (not in the final HTML).
const route: HandledRoute = {
  route: '/blog/post',
  exposeToPage: {
    manualIdle: true,
    transferState: {
      apiData: { /* ... */ }
    }
  }
};
Useful for controlling Scully’s rendering behavior or providing data to Angular during pre-render.
Specify which post-render plugins to run for this route.
const route: HandledRoute = {
  route: '/blog/post',
  postRenderers: [
    'contentRender',
    'minifyHtml',
    'customPlugin'
  ]
};
Overrides defaultPostRenderers from your config.
Use a custom render plugin instead of the default Puppeteer renderer.
const route: HandledRoute = {
  route: '/api-docs',
  renderPlugin: 'customApiRenderer'
};
Path to the source file for content routes.
const route: HandledRoute = {
  route: '/blog/my-post',
  type: 'contentFolder',
  templateFile: '/home/user/project/blog/my-post.md'
};
Used by the content plugin to read and render the markdown file.

RouteConfig Interface

The RouteConfig interface provides additional configuration options:
export interface RouteConfig {
  /** this route does a manual Idle check */
  manualIdleCheck?: boolean;
  
  /** type of the route  */
  type?: string;
  
  /** executed on render - return false to skip rendering */
  preRenderer?: (route: HandledRoute) => Promise<unknown | false>;
  
  /** option to select a different render plugin */
  renderPlugin?: string | symbol;
  
  /** Allow any other setting possible, depends on plugins */
  [key: string]: any;
}

ContentTextRoute Extension

For content files (Markdown, AsciiDoc, etc.), there’s an extended interface:
export interface ContentTextRoute extends HandledRoute {
  /** the type of content (MD/HTML) */
  contentType?: string;
  
  /** The actual raw content that will be rendered */
  content?: string | ((route?: HandledRoute) => string);
}
Example:
const contentRoute: ContentTextRoute = {
  route: '/blog/my-post',
  type: 'contentFolder',
  templateFile: './blog/my-post.md',
  contentType: 'md',
  content: '# My Post\n\nThis is the content...',
  data: {
    title: 'My Post',
    author: 'Jane Doe'
  }
};

Default Router Plugin

Routes without parameters or custom configuration use the default router plugin:
// From libs/scully/src/lib/routerPlugins/defaultRouterPlugin.ts
async function defaultRouterPlugin(route: string) {
  return [{ route } as HandledRoute];
}

registerPlugin('router', 'default', defaultRouterPlugin);
This simply converts:
'/about' → [{ route: '/about' }]

Content Folder Plugin Example

The contentFolder plugin is a common router plugin that reads files from a directory:
// From libs/scully/src/lib/routerPlugins/contentFolderPlugin.ts
export async function contentFolderPlugin(
  angularRoute: string, 
  conf: RouteTypeContentFolder
): Promise<HandledRoute[]> {
  const parts = angularRoute.split('/');
  const param = parts.filter(p => p.startsWith(':')).map(id => id.slice(1))[0];
  const paramConfig = conf[param];
  
  const baseRoute = angularRoute.split(':' + param)[0];
  const basePath = join(scullyConfig.homeFolder, paramConfig.folder);
  
  const handledRoutes = await checkSourceIsDirectoryAndRun(
    basePath, 
    baseRoute, 
    conf
  );
  
  return handledRoutes;
}
This plugin:
  1. Identifies the parameter in the route (e.g., :slug)
  2. Reads the configured folder
  3. Creates a handled route for each file found
Configuration:
export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './blog'
      }
    }
  }
};
Output:
[
  {
    route: '/blog/my-first-post',
    type: 'contentFolder',
    templateFile: '/path/to/blog/my-first-post.md',
    data: {
      title: 'My First Post',
      slug: 'my-first-post',
      sourceFile: 'blog/my-first-post.md'
    }
  },
  {
    route: '/blog/another-post',
    type: 'contentFolder',
    templateFile: '/path/to/blog/another-post.md',
    data: {
      title: 'Another Post',
      slug: 'another-post',
      sourceFile: 'blog/another-post.md'
    }
  }
]

Using Handled Route Data in Angular

Once routes are handled and rendered, the data is available in your Angular application:
import { ScullyRoutesService } from '@scullyio/ng-lib';
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-blog-post',
  template: `
    <article>
      <h1>{{ route?.data?.title }}</h1>
      <p>By {{ route?.data?.author }}</p>
      <time>{{ route?.data?.publishDate | date }}</time>
      <div>
        <span *ngFor="let tag of route?.data?.tags">{{ tag }}</span>
      </div>
    </article>
  `
})
export class BlogPostComponent implements OnInit {
  route: any;

  constructor(private scully: ScullyRoutesService) {}

  ngOnInit() {
    this.scully.getCurrent().subscribe(route => {
      this.route = route;
    });
  }
}

The scully.routes.json File

All handled routes are written to scully.routes.json with their metadata:
[
  {
    "route": "/",
    "type": "default"
  },
  {
    "route": "/blog/scully-basics",
    "type": "contentFolder",
    "sourceFile": "blog/scully-basics.md",
    "title": "Scully Basics",
    "author": "John Doe",
    "published": true,
    "publishDate": "2024-01-15",
    "tags": ["angular", "ssg"]
  },
  {
    "route": "/blog/advanced-features",
    "type": "contentFolder",
    "sourceFile": "blog/advanced-features.md",
    "title": "Advanced Features",
    "author": "Jane Smith",
    "published": true,
    "publishDate": "2024-01-20",
    "tags": ["angular", "advanced"]
  }
]
The scully.routes.json file is generated in your dist folder and can be deployed with your application to provide route metadata to your Angular app.

Creating Custom Router Plugins

You can create custom router plugins to handle any type of dynamic route:
import { registerPlugin } from '@scullyio/scully';
import { HandledRoute } from '@scullyio/scully';

async function userPlugin(route: string, config: any): Promise<HandledRoute[]> {
  // Fetch users from an API
  const response = await fetch(config.apiUrl);
  const users = await response.json();
  
  // Create a handled route for each user
  return users.map(user => ({
    route: `/user/${user.id}`,
    type: 'user',
    data: {
      id: user.id,
      name: user.name,
      email: user.email,
      avatar: user.avatar
    }
  }));
}

registerPlugin('router', 'user', userPlugin);

Best Practices

Keep route data flat and serializable:
// Good
const route: HandledRoute = {
  route: '/post',
  data: {
    title: 'My Post',
    tags: ['tag1', 'tag2'],
    publishDate: '2024-01-15'
  }
};

// Avoid
const route: HandledRoute = {
  route: '/post',
  data: {
    title: 'My Post',
    author: {
      details: {
        social: {
          twitter: { /* deep nesting */ }
        }
      }
    },
    htmlContent: '<div>...</div>' // Can break JSON
  }
};

Next Steps

Unhandled Routes

Learn about routes before they’re handled

Rendering Process

See how handled routes become HTML

Router Plugins

Create custom router plugins

Route Configuration

Configure route handling

Build docs developers (and LLMs) love