Writing Custom Scaffolder Templates for Node.js Services

Standardizing service creation across distributed engineering teams requires precise automation. When writing custom scaffolder templates for Node.js services, platform engineers must balance flexibility with strict governance. This builds directly on the patterns in Scaffolder Template Design; this guide details the exact configuration, validation steps, and edge-case handling required to deploy production-ready Node.js scaffolds within your internal developer portal.

Node.js skeleton rendering with conditional dependencies Parameters drive Nunjucks rendering of the skeleton, where conditional blocks inject optional dependencies before the repo is published and registered. Parameters name, owner, dbType Nunjucks render if dbType: add pg package.json valid JSON publish + register repo + catalog conditional blocks must still emit valid JSON, or the install fails
Conditional Nunjucks blocks decide which dependencies land in package.json; the rendered file must stay valid JSON before publish and registration.

Context: Standardizing Node.js Service Generation

The value of a scaffold is the contrast: hand-bootstrapping produces inconsistent structures and missing pipelines, while a template turns the same inputs into a governed, catalog-registered service every time.

Manual bootstrap versus scaffolded service Manual creation yields drift and missing pipelines; a scaffold yields a standardized, governed, registered service. Manual bootstrap copy + edit drift, no pipeline Scaffolded inputs governed skeleton Registered, consistent service guardrails + autonomy
The scaffold keeps developer autonomy — they still choose the inputs — while removing the drift manual setup causes.

Platform teams frequently encounter inconsistent project structures, missing CI/CD pipelines, and delayed onboarding when developers manually bootstrap repositories. By integrating a dedicated scaffolding workflow into the broader Plugin Ecosystem & Custom Extensions, organizations enforce architectural guardrails while preserving developer autonomy. The scaffolder translates high-level inputs into fully initialized Node.js projects, complete with dependency management, linting rules, and catalog metadata.

Exact Configuration: Core Template Architecture

A functional Node.js scaffold relies on three critical Backstage actions: fetch:template, publish:github (or equivalent), and catalog:register. The fetch:template action processes Nunjucks placeholders, injecting parameters like serviceName, nodeVersion, and packageManager. Ensure your template.yaml declares apiVersion: scaffolder.backstage.io/v1beta3 and maps input fields to the parameters schema. The template skeleton directory must mirror the target repository structure, with package.json containing dynamic placeholders (e.g., {{ values.serviceName }}).

Core Scaffolder Definition (template.yaml)

apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: nodejs-service-template
  title: Node.js Microservice
  description: Standardized Node.js service with TypeScript and CI/CD
spec:
  owner: platform-team
  type: service
  parameters:
    - title: Service Details
      required: [name, owner]
      properties:
        name:
          title: Service Name
          type: string
          ui:field: EntityNamePicker
        owner:
          title: Owner Group
          type: string
          ui:field: EntityPicker
          ui:options:
            allowedKinds: [Group]
  steps:
    - id: fetch
      name: Fetch Template
      action: fetch:template
      input:
        url: ./skeleton
        values:
          serviceName: ${{ parameters.name }}
          owner: ${{ parameters.owner }}
    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        allowedHosts: ['github.com']
        repoUrl: github.com?owner=${GITHUB_ORG}&repo=${{ parameters.name }}
        description: "${{ parameters.name }} service"
        defaultBranch: main
    - id: register
      name: Register Catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
        catalogInfoPath: '/catalog-info.yaml'
  output:
    links:
      - title: Repository
        url: ${{ steps['publish'].output.remoteUrl }}

The interesting part of the skeleton is conditional rendering: a Nunjucks if block injects an optional dependency, but the output must remain valid JSON or npm ci fails on the generated repo. The diagram shows that branch.

Conditional dependency rendering A dbType parameter conditionally adds the pg dependency, and the rendered package.json must stay valid JSON. dbType param postgresql? if true → add pg dependency else → omit Valid package.json npm ci succeeds
The trailing comma is the trap: a conditional block must leave the JSON well-formed on both branches.

Dynamic Package Manifest (skeleton/package.json)

Backstage’s fetch:template uses Nunjucks templating. The skeleton files use {{ values.* }} syntax:

{
  "name": "{{ values.serviceName }}",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node dist/index.js",
    "build": "tsc",
    "lint": "eslint . --ext .ts"
  },
  "dependencies": {
    "express": "^4.18.2"{% if values.dbType === 'postgresql' %},
    "pg": "^8.11.0"{% endif %},
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "@types/node": "^20.10.0",
    "@types/express": "^4.17.21"
  }
}

Validation: Pre-Flight Checks & Local Testing

The validation pipeline runs from cheapest to most thorough: validate the schema, simulate locally, execute the workflow, prove the generated project installs, and confirm the catalog metadata resolved.

Five-step template validation Schema validation, local simulation, workflow execution, an npm ci install check, and catalog metadata verification. Schema validate Local sim yarn dev Execute /create UI npm ci lockfile ok Catalog check refs resolve
Running npm ci on the generated project is the check that catches a malformed conditional before a developer does.
  1. Schema Validation: Run npx @backstage/cli catalog validate --path ./template.yaml to catch YAML syntax errors and missing required fields.
  2. Local Simulation: Start a local Backstage instance with the scaffolder backend enabled:
    yarn dev
    
  3. Workflow Execution: Trigger the template via the local UI at http://localhost:3000/create. Inspect the temporary repository output in the configured Git provider.
  4. Dependency Resolution: Navigate to the scaffolded directory and run npm ci to verify lockfile compatibility and dependency tree integrity.
  5. Catalog Metadata Verification: Confirm catalog-info.yaml correctly resolves ${{ parameters.owner }} and ${{ parameters.name }} into valid system and owner entity references.

Edge Cases: Conditional Logic & Secret Handling

The three hard cases each have a specific safe pattern: conditional dependencies stay in-skeleton with valid JSON, secrets are injected post-generation rather than templated in, and monorepo services render to a target path plus a workspace update.

Three scaffolder edge cases and their patterns Conditional dependencies, secret handling, and monorepo integration each map to a safe pattern. conditional deps registry secrets monorepo target Nunjucks if, valid JSON inject post-generation targetPath + workspace
Never template a token into a skeleton .npmrc — inject it as a CI step after the repo exists.
  • Conditional Dependencies: Use Nunjucks conditionals directly in package.json skeleton to inject optional drivers (e.g., {% if values.dbType === 'postgresql' %}...{% endif %}).
  • Private Registry Authentication: Never hardcode .npmrc tokens in skeleton files. Pass credentials securely via the Backstage backend’s secrets configuration or inject them through a CI/CD step post-generation.
  • Monorepo Integration: Configure fetch:template with targetPath: ./packages/{{ values.serviceName }}. Ensure the root package.json workspace configuration is updated to include the new service path post-generation using a custom action or a follow-up PR.

Common Pitfalls & Rapid Resolution

The Node.js scaffolder pitfalls split into template-definition mistakes (wrong apiVersion, absolute paths) and rendering-output mistakes (invalid JSON, skipped registration). The diagram groups the four so the fix lands in the right file.

Node.js scaffolder pitfalls by class Wrong apiVersion and absolute paths are definition mistakes; invalid JSON and skipped registration are output mistakes. Definition wrong apiVersion absolute paths → v1beta3 + relative Output invalid JSON no register step → valid JSON + register
A local npm ci and a mandatory catalog:register step close both classes before a developer ever runs the template.
Symptom Root Cause Resolution
Template silently ignored by Backstage Missing or incorrect apiVersion Update to apiVersion: scaffolder.backstage.io/v1beta3
CI/CD pipeline fails on fetch step Hardcoded absolute paths in fetch:template Switch to relative paths (./skeleton)
Invalid JSON in generated package.json Unescaped Nunjucks delimiters in script blocks Wrap conditional blocks in valid JSON syntax or use template helpers
Service invisible in Developer Portal Skipped catalog:register step Append catalog:register action pointing to /catalog-info.yaml

Frequently Asked Questions

How do I test a custom Node.js scaffolder template locally before publishing?

Run npx @backstage/cli catalog validate against your template.yaml, then start a local Backstage instance with yarn dev. Trigger the template via the UI, inspect the generated repository, and verify catalog-info.yaml resolves all parameters correctly before committing.

Can I enforce specific Node.js versions across all scaffolded services?

Yes. Define a fixed nodeVersion parameter in the template schema, or hardcode the engines field in the skeleton package.json. You can also use fetch:template to inject .nvmrc or .node-version files automatically into the generated repository.

What happens if the catalog registration step fails after repository creation?

The repository remains intact, but the service will not appear in the portal. Implement a retry mechanism using the catalog:register action’s built-in retry behavior, or configure a webhook in the target Git provider to trigger a catalog:refresh on push events. You can also manually register the location via the Backstage API.