- Part 1: An Extensible Typescript Code Generation Framework
- Part 2: Building Full-Stack Apps With Wasp
In Part 1, I shared the evolution of Swarm from a collection of project scripts into a full boilerplate generation framework. In this article, we'll walk through the steps of building a Wasp application using the Swarm Wasp Starter template, and I'll demonstrate how the generators work with real examples.
I'll be referencing the kitchen-sink example project from the Wasp repository throughout this article. It's a comprehensive example that features all major Wasp features, so it serves as a good reference point to understand how Swarm works.
Key Differences
Swarm implements a couple of notable changes to standard Wasp projects that it's useful to be aware of.
Directory Structure
Wasp allows you to organise projects however you like, which is appealing at face-value. But when you're starting and scaling projects, that freedom comes with the cost of cognitive burden attached as you have to determine the best way to organise your files and directories.
Taking inspiration from Prettier, the Wasp plugin aims to remove all the mental effort of deciding how project files should be organised by enforcing a convention-driven, feature-based directory structure. Each feature directory is self-contained, with client and server subdirectories organised in a consistent structure, and a feature.wasp.ts configuration file that lives alongside the components it defines.
Here's what a typical feature directory looks like:
src/features/operations/
├── feature.wasp.ts
├── client/
│ └── pages/
│ ├── Serialization.tsx
│ ├── TaskDetail.tsx
│ └── Tasks.tsx
└── server/
├── actions/
│ ├── createTask.ts
│ ├── deleteCompletedTasks.ts
│ ├── toggleAllTasks.ts
│ └── updateTaskIsDone.ts
└── queries/
├── getNumTasks.ts
├── getTask.ts
└── getTasks.ts
This is in contrast to the standard guidance that recommends a single actions.ts file containing multiple actions, or a queries.ts file containing multiple queries. As the project grows, those files become harder to navigate and may ultimately get split up into sub-files, which again has an associated cost to plan and implement. The Wasp plugin's conventions dictate that each component lives in its own file, which also harmonises server-side components with client-side ones.
The main.wasp.ts file is still required, but it's much smaller as it purely handles application-level configuration like authentication and the root component. Feature-specific configuration now lives in feature.wasp.ts files, which keeps the main file manageable even as you add more features.
Configuration Syntax
Wasp's declarative configuration syntax works fine, but it can be verbose. Swarm's extended App class provides a fluent API that makes configuration more concise and readable.
For example, typical Wasp declarations look like this:
app.route("tasks", {
path: "/tasks",
to: app.page("tasks", {
component: {
importDefault: "Tasks",
from: "@src/features/operations/client/pages/Tasks",
},
}),
});
app.crud("tasks", {
entity: "Task",
operations: {
create: {
overrideFn: {
import: "create",
from: "@src/features/crud/server/cruds/tasks" },
},
},
getAll: {
overrideFn: {
import: "getAll",
from: "@src/features/crud/server/cruds/tasks" },
},
},
},
);
While the equivalent in Swarm looks like this:
app
.addRoute("operations", "tasks", {
path: "/tasks",
auth: false
})
.addCrud("crud", "tasks", {
entity: "Task",
create: { override: true },
getAll: { override: true }
});
The fluent API lets generators chain these calls together, and Swarm organises them automatically so related declarations are grouped together and sorted alphabetically. It's a small detail, but it makes the configuration files much easier to read.
The generators automatically generate component files in consistent locations, following the plugin's structural conventions, so you don't have to define those yourself. The generated code is also fully type-safe, so if you reference an entity or property that doesn't exist in your Prisma schema, TypeScript will catch it.
Getting Started
The Swarm Wasp Starter template includes Swarm and the Wasp plugin, along with Tailwind CSS 4 and shadcn/ui components. You'll also find a collection of useful npm scripts for common Wasp workflows.
Run this command to create a new project using the starter:
npx @ingenyus/swarm create my-app --template genyus/swarm-wasp-starter
This clones the starter template and populates any templated values. Once installation is complete, navigate into the project directory and set it up:
npm run reset
Using the CLI
The Swarm framework provides CLI commands for each of the generators defined by any active plugins, while the Wasp Starter template provides access to the CLI via the npm run swarm package script.
Let's say we want to replicate the kitchen-sink application, but built using Swarm.
Prisma Schema
The first step is to define any necessary entities in our schema.prisma file:
model User {
id Int @id @default(autoincrement())
isOnAfterSignupHookCalled Boolean @default(false)
isOnAfterLoginHookCalled Boolean @default(false)
isOnAfterEmailVerifiedHookCalled Boolean @default(false)
tasks Task[]
address String?
votes TaskVote[]
}
model Task {
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
user User @relation(fields: [userId], references: [id])
userId Int
votes TaskVote[]
}
model TaskVote {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId Int
task Task @relation(fields: [taskId], references: [id])
taskId Int
}
Then we need to configure basic username authentication to enable auth settings:
main.wasp.ts
app.auth({
userEntity: "User",
methods: {
usernameAndPassword: {},
},
// Required, but not used in this tutorial
onAuthFailedRedirectTo: "/login",
});
Migrate the database and compile the project so Typescript is aware of the new entities:
wasp db migrate-dev --name init && wasp compile
Feature Directory
Before generating any Wasp components, we must first generate a feature directory to contain them:
npm run swarm -- feature --target operations
All Swarm command arguments also support a short form, which you can review by using the -h argument to display the command help. The shortened form of the previous command would be:
npm run swarm -- feature -t operations
Output
This creates the src/features/operations/ directory containing a bare feature.wasp.ts file, ready to start adding components.
feature.wasp.ts
import { App } from "@ingenyus/swarm-wasp";
export default function configureFeature(app: App, feature: string): void {
app
}
Routes & Pages
Next, we need to generate a login page (in the pre-existing root feature) and we'll also create a route for viewing tasks:
npm run swarm -- route --feature root --path /login --name login
npm run swarm -- route --feature operations --name tasks --path /tasks --auth
Output
These commands generate page components at src/features/operations/client/pages/Tasks.tsx and src/features/root/client/pages/Login.tsx, then adds definitions to the respective feature.wasp.ts files. The --auth flag indicates the route requires authentication, which is reflected in the helper method call.
root/feature.wasp.ts
app
// Route definitions
.addRoute(feature, "login", {
path: "/login",
auth: false,
});
operations/feature.wasp.ts
app
// Route definitions
.addRoute(feature, "tasks", {
path: "/tasks",
auth: true,
});
Login.tsx
import React from "react";
export const Login = () => {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4">Login</h1>
{/* TODO: Add page content */}
</div>
);
};
Tasks.tsx
import React from "react";
export const Tasks = () => {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4">Tasks</h1>
{/* TODO: Add page content */}
</div>
);
};
Operations
Next, we can generate queries and actions for Task entity:
npm run swarm -- query --feature operations --operation getAll --data-type Task --name getTasks
npm run swarm -- action --feature operations --operation create --data-type Task --name createTask
Output
These commands create boilerplate for the query and action handlers in src/features/operations/server/queries/getTasks.ts and src/features/operations/server/actions/createTask.ts respectively. The output is properly typed based on your Prisma schema, so you get full type-safety out of the box.
feature.wasp.ts
app
// Action definitions
.addAction(feature, "createTask", {
entities: ["Task"],
auth: false,
})
// Query definitions
.addQuery(feature, "getTasks", {
entities: ["Task"],
auth: false,
})
createTask.ts
import { Task } from "wasp/entities";
import { HttpError } from "wasp/server";
import type { CreateTask } from "wasp/server/operations";
export const createTask: CreateTask<Pick<Task, "description" | "userId"> & Partial<Pick<Task, "isDone">>> = async (data, context) => {
try {
const createdTask = await context.entities.Task.create({
data: {
...data,
}
});
return createdTask;
} catch (error) {
console.error("Failed to create Task:", error);
throw new HttpError(500, `Failed to create Task${error instanceof Error ? `: ${error.message}` : ''}`);
}
};
getTasks.ts
import { HttpError } from "wasp/server";
import type { GetAllTasks } from "wasp/server/operations";
export const getAllTasks = (async (_args, context) => {
try {
const tasks = await context.entities.Task.findMany();
return tasks;
} catch (error) {
console.error("Failed to get all tasks:", error);
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(500, `Failed to get all tasks${error instanceof Error ? `: ${error.message}` : ''}`);
}
}) satisfies GetAllTasks<void>;
CRUD Operations
For CRUD operations, the kitchen-sink application offers a separate crud feature. Let's generate that and a set of operations:
npm run swarm -- feature --target crud
npm run swarm -- crud --feature crud --data-type Task --override get getAll create
Output
This configures all the standard CRUD operations, with function overrides for the get, getAll and create operations. CRUD operations are a special case, with all overridden functions being exported from src/features/crud/server/cruds/tasks.ts.
feature.wasp.ts
app
// Crud definitions
.addCrud(feature, "tasks", {
entity: "Task",
get: {
override: true
},
getAll: {
override: true
},
create: {
override: true
},
update: {},
delete: {},
});
tasks.ts
import { type Task } from "wasp/entities";
import { HttpError } from "wasp/server";
import { type Tasks } from "wasp/server/crud";
export const getTask = (async ({ id }, context) => {
if (!context.user) {
throw new HttpError(401);
}
try {
const task = await context.entities.Task.findUnique({
where: { id },
});
if (!task) {
throw new HttpError(404, `task ${id} not found`);
}
return task;
} catch (error) {
console.error(`Failed to get task ${id}:`, error);
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(500, `Failed to get task ${id}${error instanceof Error ? `: ${error.message}` : ''}`);
}
}) satisfies Tasks.GetQuery<Pick<Task, "id">>;
export const getAllTasks = (async (_args, context) => {
if (!context.user) {
throw new HttpError(401);
}
try {
const tasks = await context.entities.Task.findMany();
return tasks;
} catch (error) {
console.error("Failed to get all tasks:", error);
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(500, `Failed to get all tasks${error instanceof Error ? `: ${error.message}` : ''}`);
}
}) satisfies Tasks.GetAllQuery<void>;
export const createTask: Tasks.CreateAction<Pick<Task, "description" | "userId"> & Partial<Pick<Task, "isDone">>> = async (data, context) => {
if (!context.user) {
throw new HttpError(401);
}
try {
const createdTask = await context.entities.Task.create({
data: {
...data,
}
});
return createdTask;
} catch (error) {
console.error("Failed to create Task:", error);
throw new HttpError(500, `Failed to create Task${error instanceof Error ? `: ${error.message}` : ''}`);
}
};
HTTP API Endpoints
The kitchen-sink example includes various endpoints in the apis feature, so let's add one:
npm run swarm -- feature --target apis
npm run swarm -- api --feature apis --name fooBar --method ALL --path /foo/bar --entities Task
Output
This creates the endpoint handler in src/features/apis/server/apis/fooBar.ts and registers it in your feature configuration. The generator handles the middleware setup and entity access automatically.
feature.wasp.ts
app
// Api definitions
.addApi(feature, "fooBar", {
method: "ALL",
route: "/foo/bar",
entities: ["Task"],
auth: false,
customMiddleware: true,
});
tasks.ts
import type { FooBar } from "wasp/server/api";
export const fooBar: FooBar = async (req, res, context) => {
// TODO: Implement your API logic here
res.json({ message: "OK" });
};
Running the app
While the app doesn't yet have any proper business logic, all the boilerplate is valid code and you should be able to confirm the application runs by executing:
npm run dev
After the application opens, navigate to http://localhost:3000/tasks and you should be redirected to the /login page. The page component hasn't been completed yet, but simply verifies that the application can be compiled and executed successfully.
AI-Assisted Development with MCP
The CLI works well when you know exactly what you want to generate. Sometimes it's faster to just describe your intent and let an AI assistant figure out the right commands. That's where MCP comes in.
Start the MCP server with:
npm run swarm:mcp
Once you've configured your AI tool (Cursor, Claude Code, or VS Code Copilot) to connect to the server, you can use natural language prompts. Instead of remembering the exact CLI syntax, you can say something like:
"Create an operations feature with a route to view tasks, queries and actions for the Task entity, and an authenticated API endpoint."
The AI understands your intent and calls the appropriate MCP tools, which map directly to the CLI commands. You get the same generated code, but without having to construct the command yourself.
This becomes really powerful when you're building out a feature incrementally. You might start with a basic structure, then ask the AI to add more components as you think of them. Each request generates the right files in the right places, and everything stays consistent because it's all going through the same generators.
The MCP integration means Swarm's generators are available to any AI tool that supports the protocol. You're not locked into a specific editor or assistant—if it can talk MCP, it can use Swarm.
Putting It All Together
I've created a comparison video that shows a traditional Wasp application side-by-side with one built using Swarm Wasp Starter. Both are based on the kitchen-sink example, so you can see how the same functionality looks in each approach.
The video demonstrates the complete applications, highlighting the differences in structure, configuration, and development workflow. If you're curious about how Swarm changes the day-to-day experience of building Wasp apps, this should give you a clear picture.
If you're building with Wasp, give the Swarm Wasp Starter a try!
Top comments (0)