DEV Community

Gary McPherson
Gary McPherson

Posted on

Introduction to Swarm: 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
Enter fullscreen mode Exit fullscreen mode

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" },
      },
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

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 }
  });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

Migrate the database and compile the project so Typescript is aware of the new entities:

wasp db migrate-dev --name init && wasp compile
Enter fullscreen mode Exit fullscreen mode

Feature Directory

Before generating any Wasp components, we must first generate a feature directory to contain them:

npm run swarm -- feature --target operations
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    });
Enter fullscreen mode Exit fullscreen mode

operations/feature.wasp.ts

  app
    // Route definitions
    .addRoute(feature, "tasks", {
      path: "/tasks",
      auth: true,
    });
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    })
Enter fullscreen mode Exit fullscreen mode

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}` : ''}`);
  }
}; 
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: {},
    });
Enter fullscreen mode Exit fullscreen mode

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}` : ''}`);
  }
}; 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    });
Enter fullscreen mode Exit fullscreen mode

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" });
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)