Skip to main content

Overview

Router plugins teach Scully how to fetch the data needed to pre-render pages from route parameters. They’re essential for generating routes dynamically from external data sources like APIs, databases, or file systems.

When to Use Router Plugins

Create a router plugin when you need to:
  • Fetch route data from REST APIs
  • Generate routes from database queries
  • Create routes from custom data sources
  • Process route parameters in unique ways
  • Integrate with third-party services

Router Plugin Signature

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

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

Parameters

  • route: The route string from your Scully config (e.g., /blog/:slug)
  • config: Route-specific configuration from your Scully config

Return Value

An array of HandledRoute objects wrapped in a Promise.

HandledRoute Interface

interface HandledRoute {
  route: string;              // Complete route path
  type?: string;              // Plugin type identifier
  templateFile?: string;      // Path to content file (optional)
  data?: {                    // Additional data for the route
    [key: string]: any;
  };
  config?: RouteConfig;       // Route configuration
  postRenderers?: string[];   // Post-render plugins
}

Basic Router Plugin Example

Let’s create a simple router plugin that fetches blog posts from an API:
// scully/plugins/api-router.plugin.ts
import { 
  registerPlugin, 
  HandledRoute,
  logWarn,
  yellow 
} from '@scullyio/scully';
import { httpGetJson } from '@scullyio/scully/utils/httpGetJson';

interface BlogPost {
  slug: string;
  title: string;
  author: string;
  publishedDate: string;
}

interface ApiRouterConfig {
  type: string;
  url: string;
  property?: string;
}

const apiRouterPlugin = async (
  route: string,
  config: ApiRouterConfig
): Promise<HandledRoute[]> => {
  try {
    console.log(`Fetching data from ${config.url}`);
    
    // Fetch data from API
    const posts: BlogPost[] = await httpGetJson(config.url);
    
    // Transform into HandledRoute objects
    return posts.map(post => ({
      route: `/blog/${post.slug}`,
      type: config.type,
      data: {
        title: post.title,
        author: post.author,
        publishedDate: post.publishedDate,
        slug: post.slug,
      },
    }));
  } catch (error) {
    logWarn(`Failed to fetch data from ${yellow(config.url)}:`, error);
    return [{ route, type: config.type }];
  }
};

// Register the plugin
registerPlugin('router', 'api', apiRouterPlugin);

Usage in Config

// scully.config.ts
import { ScullyConfig } from '@scullyio/scully';
import './scully/plugins/api-router.plugin';

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'my-blog',
  routes: {
    '/blog/:slug': {
      type: 'api',
      url: 'https://api.example.com/posts',
    },
  },
};

Advanced Example: JSON Router Plugin

Scully’s built-in json router plugin demonstrates a more complex pattern:
import { 
  registerPlugin, 
  HandledRoute,
  httpGetJson,
  routeSplit,
  renderTemplate,
  logError,
  yellow 
} from '@scullyio/scully';

interface JsonRouteConfig {
  type: string;
  [param: string]: {
    url: string;
    property?: string;
    resultsHandler?: (data: any) => any;
    headers?: { [key: string]: string };
  };
}

const jsonRoutePlugin = async (
  route: string,
  conf: JsonRouteConfig
): Promise<HandledRoute[]> => {
  try {
    // Parse route to extract parameters
    const { params, createPath } = routeSplit(route);
    
    // Verify all parameters have configuration
    const missingParams = params.filter(
      param => !conf.hasOwnProperty(param.part)
    );
    
    if (missingParams.length > 0) {
      logError(
        `Missing config for parameters (${missingParams.join(',')}) ` +
        `in route: ${route}. Skipping.`
      );
      return [{ route, type: conf.type }];
    }

    // Helper to load data for a parameter
    const loadData = async (param, context = {}): Promise<any[]> => {
      const url = renderTemplate(conf[param.part].url, context).trim();
      
      const rawData = await httpGetJson(url, {
        headers: conf[param.part].headers,
      });
      
      // Apply custom results handler if provided
      const processedData = conf[param.part].resultsHandler
        ? conf[param.part].resultsHandler(rawData)
        : rawData;
      
      // Extract specific property if configured
      return conf[param.part].property
        ? processedData.map(row => row[conf[param.part].property])
        : processedData;
    };

    // Build routes by reducing over parameters
    const routes = await params.reduce(
      async (total, param, index) => {
        const foundRoutes = await total;
        
        if (index === 0) {
          // First iteration: create initial route data
          return (await loadData(param)).map(r => [r]);
        }
        
        // Subsequent iterations: expand routes
        return Promise.all(
          foundRoutes.map(async (data) => {
            // Build context from previous parameters
            const context = data.reduce((ctx, r, x) => {
              return { ...ctx, [params[x].part]: r };
            }, {});
            
            // Load additional data with context
            const additionalRoutes = await loadData(param, context);
            
            // Combine with existing data
            return additionalRoutes.map(r => [...data, r]);
          })
        ).then(chunks => chunks.flat());
      },
      Promise.resolve([])
    );

    // Transform to HandledRoute objects
    return routes.map((routeData: string[]) => ({
      route: createPath(...routeData),
      type: conf.type,
    }));
  } catch (error) {
    logError(`Could not fetch data for route "${yellow(route)}"`
, error);
    return [{ route, type: conf.type }];
  }
};

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

Usage with Nested Parameters

// scully.config.ts
export const config: ScullyConfig = {
  routes: {
    '/user/:userId/post/:postId': {
      type: 'json',
      userId: {
        url: 'https://api.example.com/users',
        property: 'id',
      },
      postId: {
        url: 'https://api.example.com/users/${userId}/posts',
        property: 'id',
        resultsHandler: (data) => data.posts,
      },
    },
  },
};

Config Validation

Router plugins can include a validator function to check configuration:
import { configValidator } from '@scullyio/scully';

type ConfigValidator = (config: any) => string[] | Promise<string[]>;

const myValidator: ConfigValidator = async (config) => {
  const errors: string[] = [];
  
  if (!config.url) {
    errors.push('URL is required');
  }
  
  if (config.url && !config.url.startsWith('http')) {
    errors.push('URL must start with http:// or https://');
  }
  
  return errors;
};

// Register with validator
registerPlugin(
  'router',
  'api',
  apiRouterPlugin,
  myValidator  // Pass validator as 4th argument
);
If validation returns any errors, Scully will display them and prevent the build from continuing.

Working with Route Parameters

Use routeSplit to parse parameterized routes:
import { routeSplit } from '@scullyio/scully';

const { params, createPath } = routeSplit('/blog/:category/:slug');

console.log(params);
// [
//   { part: 'category', isParam: true },
//   { part: 'slug', isParam: true }
// ]

// Create paths with actual values
const route1 = createPath('angular', 'my-post');
// '/blog/angular/my-post'

HTTP Utilities

Scully provides httpGetJson for making API requests:
import { httpGetJson } from '@scullyio/scully/utils/httpGetJson';

const data = await httpGetJson('https://api.example.com/data', {
  headers: {
    'Authorization': 'Bearer token',
    'Content-Type': 'application/json',
  },
});

Template Rendering

Use renderTemplate to create dynamic URLs:
import { renderTemplate } from '@scullyio/scully';

const context = { userId: '123', year: '2024' };
const url = renderTemplate(
  'https://api.example.com/users/${userId}/posts/${year}',
  context
);
// 'https://api.example.com/users/123/posts/2024'

Error Handling

Always handle errors gracefully in router plugins:
const safeRouterPlugin = async (
  route: string,
  config: any
): Promise<HandledRoute[]> => {
  try {
    const data = await fetchData(config.url);
    return transformToRoutes(data);
  } catch (error) {
    // Log the error
    logError(`Router plugin error for ${yellow(route)}:`, error);
    
    // Return a fallback route
    return [{ 
      route, 
      type: config.type,
      data: { error: 'Failed to fetch data' }
    }];
  }
};

Best Practices

1. Cache API Responses

Avoid redundant API calls:
const cache = new Map();

const cachedFetch = async (url: string) => {
  if (cache.has(url)) {
    return cache.get(url);
  }
  
  const data = await httpGetJson(url);
  cache.set(url, data);
  return data;
};

2. Provide Progress Feedback

import { printProgress, yellow } from '@scullyio/scully';

printProgress(undefined, `Loading data for "${yellow(route)}"`);

3. Handle Missing Data

if (!posts || posts.length === 0) {
  logWarn(`No data found for route: ${route}`);
  return [];
}

4. Validate Route Configuration

Always check that required config properties exist:
if (!config.url) {
  throw new Error(`URL is required for api router plugin`);
}

5. Include Metadata

Add useful metadata to routes:
return posts.map(post => ({
  route: `/blog/${post.slug}`,
  type: config.type,
  data: {
    ...post,
    sourceUrl: config.url,
    fetchedAt: new Date().toISOString(),
  },
}));

Testing Router Plugins

Test your router plugins thoroughly:
import { findPlugin } from '@scullyio/scully';
import './api-router.plugin';

describe('apiRouterPlugin', () => {
  it('should fetch and transform routes', async () => {
    const plugin = findPlugin('router', 'api');
    
    const routes = await plugin('/blog/:slug', {
      type: 'api',
      url: 'https://api.example.com/posts',
    });
    
    expect(routes).toBeInstanceOf(Array);
    expect(routes.length).toBeGreaterThan(0);
    expect(routes[0]).toHaveProperty('route');
    expect(routes[0]).toHaveProperty('type');
  });
});

Complete Example: GitHub Router Plugin

Here’s a complete example that fetches repositories from GitHub:
// scully/plugins/github-router.plugin.ts
import { 
  registerPlugin, 
  HandledRoute,
  httpGetJson,
  logOk,
  yellow 
} from '@scullyio/scully';

interface GitHubRepo {
  name: string;
  description: string;
  stargazers_count: number;
  html_url: string;
}

interface GitHubRouterConfig {
  type: string;
  username: string;
  baseRoute?: string;
}

const githubRouterPlugin = async (
  route: string,
  config: GitHubRouterConfig
): Promise<HandledRoute[]> => {
  const { username, baseRoute = '/repos' } = config;
  const url = `https://api.github.com/users/${username}/repos`;
  
  try {
    const repos: GitHubRepo[] = await httpGetJson(url, {
      headers: {
        'User-Agent': 'Scully-Static-Site-Generator',
      },
    });
    
    logOk(
      `Fetched ${repos.length} repositories for ${yellow(username)}`
    );
    
    return repos.map(repo => ({
      route: `${baseRoute}/${repo.name}`,
      type: config.type,
      data: {
        name: repo.name,
        description: repo.description,
        stars: repo.stargazers_count,
        url: repo.html_url,
      },
    }));
  } catch (error) {
    throw new Error(
      `Failed to fetch GitHub repos for ${username}: ${error.message}`
    );
  }
};

const validator = async (config: GitHubRouterConfig) => {
  const errors: string[] = [];
  
  if (!config.username) {
    errors.push('GitHub username is required');
  }
  
  return errors;
};

registerPlugin('router', 'github', githubRouterPlugin, validator);

export const githubRouter = 'github';

Usage

// scully.config.ts
import './scully/plugins/github-router.plugin';

export const config: ScullyConfig = {
  routes: {
    '/repos/:repo': {
      type: 'github',
      username: 'scullyio',
    },
  },
};

Next Steps

Build docs developers (and LLMs) love