DEV Community

Gary McPherson
Gary McPherson

Posted on

Introduction to Swarm: Creating Custom Plugins

In Part 2, we explored how Swarm's Wasp plugin accelerates Wasp application development by generating context-aware boilerplate code. Now, let's dive deeper into Swarm's extensibility by building a plugin for Next.js from scratch. We'll create a route generator that supports Next.js's flexible routing system, including App Router, Pages Router, dynamic routes, and route groups.

Why Next.js?

Next.js offers powerful routing capabilities through its file-based routing system, but creating routes manually can be repetitive. Whether you're using the App Router (app directory) or Pages Router (pages directory), you often find yourself creating similar file structures, component boilerplate, and configuration patterns.

A Swarm plugin can automate this process while respecting Next.js conventions and supporting the framework's various routing patterns:

  • Static and dynamic routes ([slug], [...slug], [[...slug]])
  • Route groups for organization ((group))
  • Private folders (_folder)
  • Special files (page.tsx, layout.tsx, loading.tsx, error.tsx, route.ts)

Setting Up the Plugin Structure

Let's start by creating a new plugin package. We'll call it @ingenyus/swarm-nextjs:

packages/swarm-nextjs/
├── src/
│   ├── generators/
│   │   ├── base/
│   │   │   ├── nextjs-generator.base.ts
│   │   │   └── index.ts
│   │   └── route/
│   │       ├── route-generator.ts
│   │       ├── schema.ts
│   │       └── index.ts
│   ├── common/
│   │   ├── config.ts
│   │   ├── path-resolver.ts
│   │   └── index.ts
│   ├── plugin.ts
│   └── index.ts
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

First, we need to install Swarm as a dependency:

{
  "name": "@ingenyus/swarm-nextjs",
  "version": "0.1.0",
  "dependencies": {
    "@ingenyus/swarm": "^1.0.0",
    "zod": "^4.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Configuration System

One of the key features of the Next.js plugin is its flexible configuration system that allows developers to define custom directory structures using token-based path templates. This means you can customize how files are organized without modifying the plugin code.

Path Template Tokens

The plugin supports the following tokens in path templates:

  • {router}: 'app' or 'pages'
  • {src}: 'src/' if useSrcFolder is true, '' otherwise
  • {route}: Full route path including route groups (e.g., "(marketing)/blog/[slug]")
  • {routeClean}: Route path without route groups (e.g., "blog/[slug]")
  • {componentType}: Directory name for component type (components, lib, hooks, utils)
  • {fileName}: The generated file name (e.g., "page.tsx", "PostCard.tsx")

Default Configuration

The plugin provides sensible defaults that follow Next.js best practices. By default, it uses a "feature-based" structure where components are colocated with their routes:

// src/common/config.ts
export const DEFAULT_PATH_TEMPLATES: Required<NextJSPathTemplate> = {
  route: '{src}{router}/{routeClean}/{fileName}',
  component: '{src}{router}/{routeClean}/{componentType}/{fileName}',
  lib: '{src}{router}/{routeClean}/{componentType}/{fileName}',
  hook: '{src}{router}/{routeClean}/{componentType}/{fileName}',
  util: '{src}{router}/{routeClean}/{componentType}/{fileName}',
  serverAction: '{src}{router}/{routeClean}/actions/{fileName}',
  apiRoute: '{src}{router}/{routeClean}/route.ts',
};
Enter fullscreen mode Exit fullscreen mode

This means a route at blog/[slug] would generate files at:

  • Route: app/blog/[slug]/page.tsx
  • Components: app/blog/[slug]/components/PostCard.tsx
  • Utils: app/blog/[slug]/lib/utils.ts

Defining the Route Generator Schema

The schema defines what parameters our generator accepts and how they're validated. For Next.js routes, we need to support multiple routing patterns and directory structures.

Let's create src/generators/route/schema.ts:

import { registerSchemaMetadata } from '@ingenyus/swarm';
import { z } from 'zod/v4';

// Route type determines which Next.js file convention to use
const routeTypeEnum = z.enum([
  'page',      // app/[path]/page.tsx or pages/[path].tsx
  'layout',    // app/[path]/layout.tsx
  'loading',   // app/[path]/loading.tsx
  'error',     // app/[path]/error.tsx
  'not-found', // app/not-found.tsx
  'route',     // app/api/[path]/route.ts (API route handler)
]);

// Router type determines which directory structure to use
const routerTypeEnum = z.enum(['app', 'pages']);

const baseSchema = z.object({
  // Route path (e.g., "blog/[slug]", "shop/[...slug]", "(marketing)/about")
  path: z
    .string()
    .min(1, 'Route path is required')
    .describe('The route path following Next.js conventions'),

  // Route type determines the file convention
  type: routeTypeEnum.default('page'),

  // Router type: App Router (app/) or Pages Router (pages/)
  router: routerTypeEnum.default('app'),

  // Component name (optional, derived from path if not provided)
  name: z.string().optional(),

  // Whether this is a client component (App Router only)
  client: z.boolean().default(false),

  // Whether to generate metadata export
  metadata: z.boolean().default(false),

  // Force overwrite existing files
  force: z.boolean().default(false),
});

export const schema = registerSchemaMetadata(baseSchema, {
  fields: {
    path: {
      type: 'string',
      required: true,
      description: 'The route path following Next.js conventions',
      shortName: 'p',
      examples: [
        'blog/[slug]',
        'shop/[...slug]',
        '(marketing)/about',
        'api/users/route',
      ],
      helpText: 'Supports dynamic segments ([slug]), catch-all ([...slug]), optional catch-all ([[...slug]]), route groups ((group)), and private folders (_folder)',
    },
    type: {
      type: 'enum',
      required: false,
      description: 'The type of route file to generate',
      shortName: 't',
      examples: ['page', 'layout', 'loading', 'error', 'route'],
      enumValues: ['page', 'layout', 'loading', 'error', 'not-found', 'route'],
      helpText: 'Determines which Next.js file convention to use',
    },
    router: {
      type: 'enum',
      required: false,
      description: 'Which router to use: App Router (app/) or Pages Router (pages/)',
      shortName: 'r',
      examples: ['app', 'pages'],
      enumValues: ['app', 'pages'],
      helpText: 'App Router supports more features (layouts, loading states, etc.)',
    },
    name: {
      type: 'string',
      required: false,
      description: 'Component name (auto-derived from path if not provided)',
      shortName: 'n',
      examples: ['BlogPost', 'UserProfile'],
    },
    client: {
      type: 'boolean',
      required: false,
      description: 'Generate as a client component (App Router only)',
      shortName: 'c',
      helpText: 'Adds "use client" directive at the top of the file',
    },
    metadata: {
      type: 'boolean',
      required: false,
      description: 'Generate metadata export',
      shortName: 'm',
      helpText: 'Adds a metadata export for SEO',
    },
    force: {
      type: 'boolean',
      required: false,
      description: 'Force overwrite existing files',
      shortName: 'f',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This schema supports:

  • Dynamic routes: blog/[slug], shop/[...slug], docs/[[...slug]]
  • Route groups: (marketing)/about, (shop)/cart
  • Private folders: _components/Button (not routable)
  • API routes: api/users/route (with type: 'route')
  • Special files: layouts, loading states, error boundaries

Base Generator Class

Before implementing the route generator, we need a base class that handles configuration loading and path resolution. This allows all Next.js generators to share common functionality.

// src/generators/base/nextjs-generator.base.ts
import {
  GeneratorBase,
  GeneratorServices,
  StandardSchemaV1,
  getConfigManager,
  Config,
} from '@ingenyus/swarm';
import {
  NextJSPluginConfig,
  DEFAULT_NEXTJS_CONFIG,
  PRESET_TEMPLATES,
} from '../../common/config';
import { PathResolver } from '../../common/path-resolver';

export abstract class NextJSGeneratorBase<S extends StandardSchemaV1>
  extends GeneratorBase<S>
{
  protected readonly pluginName = 'nextjs';
  private configCache: RequiredNextJSConfig | null = null;
  private pathResolver: PathResolver | null = null;

  constructor(services: GeneratorServices) {
    super(services);
  }

  /**
   * Get the Next.js plugin configuration
   * Loads config from swarm.config.json, merges with defaults, and handles preset structures
   */
  protected async getPluginConfig(): Promise<RequiredNextJSConfig> {
    // ...loads and caches configuration
  }

  /**
   * Get the path resolver instance
   */
  protected async getPathResolver(): Promise<PathResolver> {
    // ...returns cached PathResolver
  }

  /**
   * Resolve route file path using configured template
   */
  protected async resolveRoutePath(
    routePath: string,
    fileName: string,
    router?: 'app' | 'pages'
  ): Promise<string> {
    // ...uses PathResolver to resolve route paths
  }

  /**
   * Resolve component file path
   */
  protected async resolveComponentPath(
    routePath: string,
    componentType: 'components' | 'lib' | 'hooks' | 'utils',
    fileName: string,
    router?: 'app' | 'pages'
  ): Promise<string> {
    // ...uses PathResolver to resolve component paths
  }

  /**
   * Resolve server action file path
   */
  protected async resolveServerActionPath(
    routePath: string,
    fileName: string,
    router?: 'app' | 'pages'
  ): Promise<string> {
    // ...uses PathResolver to resolve server action paths
  }

  /**
   * Resolve API route file path
   */
  protected async resolveApiRoutePath(
    routePath: string,
    router?: 'app' | 'pages'
  ): Promise<string> {
    // ...uses PathResolver to resolve API route paths
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Route Generator

Now let's implement the route generator that extends our base class. The core generate() method orchestrates the route generation process:

// src/generators/route/route-generator.ts
import {
  GeneratorServices,
  Out,
  toPascalCase,
  formatDisplayName,
} from '@ingenyus/swarm';
import { NextJSGeneratorBase } from '../base';
import { schema } from './schema';

export class RouteGenerator extends NextJSGeneratorBase<typeof schema> {
  name = 'route';
  description = 'Generates a Next.js route (page, layout, API route, etc.)';
  schema = schema;

  constructor(services: GeneratorServices) {
    super(services);
  }

  async generate(args: Out<typeof schema>): Promise<void> {
    const {
      path: routePath,
      type,
      router,
      name,
      client,
      metadata,
      force,
    } = args;

    const config = await this.getPluginConfig();
    const routerType = router ?? config.defaultRouter;

    // Parse the route path to handle dynamic segments, route groups, etc.
    const { componentName, params } = this.parseRoutePath(
      routePath,
      type,
      name
    );

    // Determine the file name based on route type
    const fileName = this.getFileName(type, routerType);

    // Resolve the target file path using the configured template
    const targetFile = await this.resolveRoutePath(
      routePath,
      fileName,
      routerType
    );

    // Ensure directory exists
    const dirPath = this.path.dirname(targetFile);
    if (!this.fileSystem.existsSync(dirPath)) {
      this.fileSystem.mkdirSync(dirPath, { recursive: true });
    }

    // Generate component code
    const componentCode = await this.generateComponentCode({
      componentName,
      type,
      router: routerType,
      client,
      metadata,
      params: params.map((p) => p.name),
      paramDetails: params,
    });

    // Write file
    const exists = this.fileSystem.existsSync(targetFile);
    if (exists && !force) {
      this.logger.warn(
        `File ${targetFile} already exists. Use --force to overwrite.`
      );
      return;
    }

    this.fileSystem.writeFileSync(targetFile, componentCode);
    this.logger.success(`Generated ${type} route: ${targetFile}`);
  }

  /**
   * Parse route path to extract component name and params
   * Handles dynamic segments ([slug]), catch-all ([...slug]), and route groups ((group))
   */
  private parseRoutePath(
    routePath: string,
    type: string,
    customName?: string
  ): {
    componentName: string;
    params: Array<{ name: string; isCatchAll: boolean }>;
  } {
    // ...extracts params and derives component name
  }

  /**
   * Derive component name from route path
   * Removes brackets, catch-all prefix, and route groups
   */
  private deriveComponentName(filePath: string, type: string): string {
    // ...cleans path and converts to PascalCase
  }

  /**
   * Get the file name based on route type and router
   */
  private getFileName(type: string, router: string): string {
    // ...maps route types to Next.js file conventions
  }

  /**
   * Generate component code based on route type
   */
  private async generateComponentCode(options: {
    componentName: string;
    type: string;
    router: string;
    client: boolean;
    metadata: boolean;
    params: string[];
    paramDetails: Array<{ name: string; isCatchAll: boolean }>;
  }): Promise<string> {
    // ...generates React component or API route handler code
  }

  /**
   * Generate props interface for dynamic routes
   */
  private generatePropsInterface(
    componentName: string,
    paramDetails: Array<{ name: string; isCatchAll: boolean }>,
    type: string
  ): string {
    // ...generates TypeScript interface for component props
  }

  /**
   * Get component props signature
   */
  private getComponentProps(
    componentName: string,
    type: string,
    paramDetails: Array<{ name: string; isCatchAll: boolean }>
  ): string {
    // ...generates props destructuring signature
  }

  /**
   * Generate component body based on type
   */
  private generateComponentBody(componentName: string, type: string): string {
    // ...generates JSX body for different route types
  }

  /**
   * Generate API route handler
   */
  private generateRouteHandler(handlerName: string, params: string[]): string {
    // ...generates Next.js API route handler
  }

  /**
   * Generate metadata export for SEO
   */
  private generateMetadataExport(componentName: string): string {
    // ...generates Next.js metadata export
  }
}
Enter fullscreen mode Exit fullscreen mode

For the complete implementation including all method bodies, see the full source code on GitHub Gist.

The generate() method follows a clear flow:

  1. Load configuration - Gets plugin config and determines router type
  2. Parse route path - Extracts component name and dynamic params
  3. Resolve file path - Uses configured path template to determine target location
  4. Generate code - Creates the component/route handler code
  5. Write file - Writes to disk with force flag handling

Key supporting methods handle:

  • parseRoutePath() - Extracts dynamic segments and derives component names, properly handling catch-all routes ([...slug]) vs regular dynamic routes ([slug])
  • deriveComponentName() - Cleans route paths to create valid TypeScript identifiers
  • getFileName() - Maps route types to Next.js file conventions

For the complete implementation including all helper methods (generateComponentCode, generatePropsInterface, generateComponentBody, etc.), see the full source code.

Creating the Plugin

Now let's create the plugin file src/plugin.ts:

import { createPlugin } from '@ingenyus/swarm';
import { RouteGenerator } from './generators/route';

export const nextjs = createPlugin(
  'nextjs',
  RouteGenerator
);
Enter fullscreen mode Exit fullscreen mode

And export it from src/index.ts:

export { nextjs } from './plugin';
export { RouteGenerator } from './generators/route';
Enter fullscreen mode Exit fullscreen mode

Configuring the Plugin

The plugin can be configured in your swarm.config.json or package.json file. The configuration allows you to customize directory structures using path templates:

{
  "plugins": [
    {
      "import": "nextjs",
      "from": "@ingenyus/swarm-nextjs",
      "config": {
        "useSrcFolder": false,
        "defaultRouter": "app",
        "directories": {
          "components": "components",
          "lib": "lib",
          "hooks": "hooks",
          "utils": "utils",
          "actions": "actions"
        },
        "paths": {
          "route": "{src}{router}/{routeClean}/{fileName}",
          "component": "{src}{router}/{routeClean}/{componentType}/{fileName}"
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Custom Directory Structures

You can define custom structures by overriding path templates. For example, to use a shared-components structure:

{
  "plugins": [
    {
      "import": "nextjs",
      "from": "@ingenyus/swarm-nextjs",
      "config": {
        "paths": {
          "route": "{src}{router}/{routeClean}/{fileName}",
          "component": "{src}{router}/{componentType}/{fileName}",
          "lib": "{src}{router}/{componentType}/{fileName}"
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This would generate components in app/components/ instead of colocating them with routes.

Using the Generator

Now, you can set up Swarm's MCP integration to use your preferred assistant, or generate routes using the CLI:

# Generate a simple page
npm run swarm -- route --path blog

# Generate a dynamic route
npm run swarm -- route --path 'blog/[slug]'

# Generate a catch-all route
npm run swarm -- route --path 'shop/[...slug]'

# Generate a layout
npm run swarm -- route --path dashboard --type layout

# Generate an API route
npm run swarm -- route --path api/users --type route

# Use Pages Router
npm run swarm -- route --path contact --router pages
Enter fullscreen mode Exit fullscreen mode

Example Outputs

Static Page (app/blog/page.tsx)

import React from 'react';

export default function Blog() {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-4">Blog</h1>
      {/* TODO: Add page content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Route (app/blog/[slug]/page.tsx)

import React from 'react';

interface BlogPostProps {
  params: {
    slug: string;
  };
}

export default function BlogPost(props: BlogPostProps) {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-4">Blog Post</h1>
      {/* TODO: Add page content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Catch-All Route (app/shop/[...slug]/page.tsx)

import React from 'react';

interface SlugProps {
  params: {
    slug: string[];
  };
}

export default function Slug(props: SlugProps) {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-4">Slug</h1>
      {/* TODO: Add page content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note that catch-all routes correctly generate string[] types for the params, and the component name is properly derived (removing the ... prefix and brackets).

API Route (app/api/users/route.ts)

import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  // TODO: Implement GET handler
  return NextResponse.json({ message: 'GET handler' });
}

export async function POST(request: NextRequest) {
  // TODO: Implement POST handler
  return NextResponse.json({ message: 'POST handler' });
}
Enter fullscreen mode Exit fullscreen mode

Layout (app/dashboard/layout.tsx)

import React from 'react';

interface DashboardLayoutProps {
  children: React.ReactNode;
}

export default function DashboardLayout({ children }: DashboardLayoutProps) {
  return (
    <div>
      {/* TODO: Add layout content */}
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Loading State (app/blog/loading.tsx)

import React from 'react';

export default function BlogLoading() {
  return (
    <div className="flex items-center justify-center p-8">
      <div className="text-lg">Loading Blog...</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Error Boundary (app/blog/error.tsx)

import React from 'react';

interface BlogErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function BlogError({ error, reset }: BlogErrorProps) {
  return (
    <div className="flex flex-col items-center justify-center p-8">
      <h2 className="text-xl font-bold mb-4">Something went wrong!</h2>
      <p className="text-red-600 mb-4">{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next Steps

This route generator demonstrates the core concepts of building a Swarm plugin, but there's much more we could add:

  1. Component Generator: Generate reusable React components with props interfaces
  2. Server Action Generator: Generate server actions for form handling
  3. API Route Handler Generator: More sophisticated API route generation with validation
  4. Layout Generator: Generate nested layouts with proper TypeScript types
  5. Metadata Generator: Standalone metadata generation for SEO

Each generator would follow the same pattern: define a schema, implement the generator class, create templates, and register it with the plugin. The framework handles CLI command generation, MCP tool exposure and validation.

If you're building with Next.js and want to accelerate your development workflow, consider building your own Swarm plugin!

Top comments (0)