- Part 1: An Extensible Typescript Code Generation Framework
- Part 2: Building Full-Stack Apps With Wasp
- Part 3: Creating a NextJS Plugin
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
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"
}
}
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',
};
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',
},
},
});
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(withtype: '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
}
}
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
}
}
For the complete implementation including all method bodies, see the full source code on GitHub Gist.
The generate() method follows a clear flow:
- Load configuration - Gets plugin config and determines router type
- Parse route path - Extracts component name and dynamic params
- Resolve file path - Uses configured path template to determine target location
- Generate code - Creates the component/route handler code
- 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
);
And export it from src/index.ts:
export { nextjs } from './plugin';
export { RouteGenerator } from './generators/route';
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}"
}
}
}
]
}
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}"
}
}
}
]
}
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
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>
);
}
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>
);
}
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>
);
}
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' });
}
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>
);
}
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>
);
}
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>
);
}
Next Steps
This route generator demonstrates the core concepts of building a Swarm plugin, but there's much more we could add:
- Component Generator: Generate reusable React components with props interfaces
- Server Action Generator: Generate server actions for form handling
- API Route Handler Generator: More sophisticated API route generation with validation
- Layout Generator: Generate nested layouts with proper TypeScript types
- 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)