Skip to main content
Router plugins are the heart of Scully’s route discovery system. They discover and generate routes dynamically by fetching data from external sources, reading files, or using custom logic.

Purpose

Router plugins are responsible for:
  • Discovering routes that contain parameters (e.g., /blog/:slug)
  • Fetching data from APIs, databases, or file systems
  • Generating a list of concrete routes from route patterns
  • Providing route-specific configuration and metadata

Function Signature

type RoutePlugin = {
  (route?: string, config?: any): Promise<HandledRoute[]>;
  [configValidator]?: ConfigValidator | undefined;
};

type ConfigValidator = (RouteConfig) => ErrorString[] | Promise<ErrorString[]>;

interface HandledRoute {
  /** the _complete_ route */
  route: string;
  /** String, must be an existing plugin name */
  type?: string;
  /** the relevant part of the scully-config */
  config?: RouteConfig;
  /** the path to the file for a content file */
  templateFile?: string;
  /** additional data that will end up in scully.routes.json */
  data?: RouteData;
  /** optional title */
  title?: string;
  // ... additional properties
}

Lifecycle

  1. Scully parses your Angular routes and identifies routes with parameters
  2. For each parameterized route, Scully looks up the corresponding router plugin by type
  3. The router plugin is called with the route pattern and configuration
  4. The plugin returns an array of HandledRoute objects with concrete routes
  5. These routes are added to the route list for rendering

When to Use

Create a router plugin when you need to:
  • Generate routes from a content folder (blog posts, documentation)
  • Fetch route data from an external API
  • Generate routes from a database
  • Create routes based on custom business logic
  • Handle routes with parameters that need to be resolved

Implementation Example

Basic Router Plugin

import { registerPlugin } from '@scullyio/scully';
import { HandledRoute } from '@scullyio/scully';

export const myRouterPlugin = async (
  route: string,
  config: any
): Promise<HandledRoute[]> => {
  // Extract the parameter from the route
  // e.g., "/blog/:slug" -> "slug"
  const param = route.split(':')[1];
  
  // Fetch or generate data
  const items = await fetchDataFromSource(config.source);
  
  // Convert data into HandledRoute objects
  return items.map(item => ({
    route: route.replace(`:${param}`, item.slug),
    type: config.type,
    data: {
      title: item.title,
      author: item.author,
      // ... other metadata
    }
  }));
};

// Register the plugin
registerPlugin('router', 'myPlugin', myRouterPlugin);

JSON API Router Plugin

Here’s a real example from Scully that fetches routes from a JSON API:
export const jsonRoutePlugin = async (
  route: string,
  conf: RouteTypeJson
): Promise<HandledRoute[]> => {
  // Parse the route pattern
  const { params, createPath } = routeSplit(route);
  
  // Validate that all parameters have configuration
  const missingParams = params.filter(param => !conf.hasOwnProperty(param.part));
  if (missingParams.length > 0) {
    console.error(`Missing config for parameters (${missingParams.join(',')})`)
    return [{ route, type: conf.type }];
  }
  
  // Fetch data for each parameter
  const loadData = (param, context = {}): Promise<any[]> => {
    const url = renderTemplate(conf[param.part].url, context).trim();
    return httpGetJson(url, {
      headers: conf[param.part].headers,
    })
      .then(rawData => 
        conf[param.part].property === undefined 
          ? rawData 
          : rawData.map(row => deepGet(conf[param.part].property, row))
      );
  };
  
  // Build routes by combining data from all parameters
  const routes = await params.reduce(async (total, param, col) => {
    const foundRoutes = await total;
    if (col === 0) {
      return (await loadData(param)).map(r => [r]);
    }
    return await Promise.all(
      foundRoutes.map(async (data) => {
        const context = data.reduce((ctx, r, x) => {
          return { ...ctx, [params[x].part]: r };
        }, {});
        const additionalRoutes = await loadData(param, context);
        return additionalRoutes.map(r => [...data, r]);
      })
    ).then(chunks => chunks.reduce((acc, cur) => acc.concat(cur)));
  }, Promise.resolve([]));
  
  return routes.map((routeData: string[]) => ({
    route: createPath(...routeData),
    type: conf.type,
  }));
};

registerPlugin('router', 'json', jsonRoutePlugin);

Content Folder Router Plugin

This example scans a folder and generates routes from files:
export async function contentFolderPlugin(
  angularRoute: string,
  conf: RouteTypeContentFolder
): Promise<HandledRoute[]> {
  // Extract parameter from route
  const param = angularRoute.split('/').find(p => p.startsWith(':'))?.slice(1);
  const paramConfig = conf[param];
  
  if (!paramConfig) {
    console.error(`Missing config for parameter (${param})`);
    return [];
  }
  
  const baseRoute = angularRoute.split(':' + param)[0];
  const basePath = join(scullyConfig.homeFolder, paramConfig.folder);
  
  // Recursively scan directory
  const files = await readDirectory(basePath);
  const handledRoutes: HandledRoute[] = [];
  
  for (const file of files) {
    const ext = extname(file);
    const templateFile = join(basePath, file);
    
    if (hasContentPlugin(ext)) {
      const { meta } = await readFileAndCheckPrePublishSlug(templateFile);
      const slug = meta.slug || basename(file, ext);
      
      handledRoutes.push({
        route: `${baseRoute}${slugify(slug)}`,
        type: conf.type,
        templateFile,
        data: { ...meta, sourceFile: file },
      });
    }
  }
  
  return handledRoutes;
}

registerPlugin('router', 'contentFolder', contentFolderPlugin);

Configuration in scully.config.ts

Register your router plugin in your Scully configuration:
import { ScullyConfig } from '@scullyio/scully';
import './my-router-plugin';

export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'myPlugin',
      source: 'https://api.example.com/posts',
      // ... additional config passed to the plugin
    },
  },
};

Config Validator

You can optionally provide a config validator to validate the configuration:
const myValidator = async (conf) => {
  const errors: string[] = [];
  
  if (!conf.source) {
    errors.push('Missing required "source" property');
  }
  
  if (conf.timeout && typeof conf.timeout !== 'number') {
    errors.push('"timeout" must be a number');
  }
  
  return errors;
};

registerPlugin('router', 'myPlugin', myRouterPlugin, myValidator);

Best Practices

  1. Always validate input: Check that required configuration is present
  2. Handle errors gracefully: Return an empty route or default route on error
  3. Return absolute routes: All routes must start with /
  4. Avoid query params and hashes: Scully will strip these out
  5. Use slugify for URLs: Convert file names and titles to URL-safe strings
  6. Add metadata to data: Include all relevant data for use in templates
  7. Log progress: Use Scully’s logging utilities to provide feedback
  8. Support recursive structures: Handle nested folders or hierarchical data

Built-in Router Plugins

Scully includes several built-in router plugins:
  • json: Fetch routes from JSON APIs
  • contentFolder: Generate routes from markdown/content files
  • ignored: Mark routes to be ignored
  • default: Handle routes without parameters

Build docs developers (and LLMs) love