TL;DR
Wasp is the DSL-powered full-stack framework — declarative configuration language, auto-generates React + Node.js apps, built-in auth/jobs/email, one command deploy. RedwoodJS is the opinionated full-stack framework — React frontend, GraphQL API, Prisma ORM, cells for data fetching, built by the creator of Jekyll. BlitzJS is the monolithic full-stack toolkit — built on Next.js, zero-API data layer (RPC), scaffolding, auth, conventions over configuration. In 2026: Wasp for rapid prototyping with declarative config, RedwoodJS for structured full-stack with GraphQL, BlitzJS for Next.js with zero-API data access.
Key Takeaways
- Wasp: wasp ~10K weekly downloads — DSL-based, code generation, built-in auth/jobs
- RedwoodJS: @redwoodjs/core ~15K weekly downloads — GraphQL API, Cells, Prisma, opinionated
- BlitzJS: blitz ~8K weekly downloads — Next.js based, zero-API RPC, scaffolding
- Wasp uses a custom DSL to declaratively define your full-stack app
- RedwoodJS has the most structured architecture (Cells, Services, GraphQL)
- BlitzJS extends Next.js with server functions and built-in auth
Wasp
Wasp — declarative full-stack framework:
Wasp configuration (DSL)
// main.wasp — declarative app configuration:
app PkgPulse {
wasp: { version: "^0.14.0" },
title: "PkgPulse",
auth: {
userEntity: User,
methods: {
usernameAndPassword: {},
google: {},
github: {},
},
onAuthFailedRedirectTo: "/login",
},
db: {
system: PostgreSQL,
},
emailSender: {
provider: SendGrid,
},
}
// Entities (maps to Prisma schema):
entity User {=psl
id Int @id @default(autoincrement())
email String @unique
name String?
plan String @default("free")
packages Package[]
psl=}
entity Package {=psl
id Int @id @default(autoincrement())
name String @unique
description String?
downloads Int @default(0)
version String
userId Int
user User @relation(fields: [userId], references: [id])
psl=}
// Queries and Actions:
query getPackages {
fn: import { getPackages } from "@src/packages/queries",
entities: [Package],
}
query getPackage {
fn: import { getPackage } from "@src/packages/queries",
entities: [Package],
}
action comparePackages {
fn: import { comparePackages } from "@src/packages/actions",
entities: [Package],
}
// Pages and Routes:
route HomeRoute { path: "/", to: HomePage }
page HomePage {
component: import { HomePage } from "@src/pages/Home",
}
route CompareRoute { path: "/compare", to: ComparePage }
page ComparePage {
authRequired: true,
component: import { ComparePage } from "@src/pages/Compare",
}
// Background Jobs:
job syncPackages {
executor: PgBoss,
perform: {
fn: import { syncPackages } from "@src/jobs/syncPackages",
},
schedule: {
cron: "0 */6 * * *", // Every 6 hours
},
entities: [Package],
}
Server-side code
// src/packages/queries.ts
import { GetPackages, GetPackage } from "wasp/server/operations"
export const getPackages: GetPackages<void, Package[]> = async (_args, context) => {
return context.entities.Package.findMany({
orderBy: { downloads: "desc" },
take: 50,
})
}
export const getPackage: GetPackage<{ name: string }, Package> = async ({ name }, context) => {
const pkg = await context.entities.Package.findUnique({
where: { name },
})
if (!pkg) throw new HttpError(404, "Package not found")
return pkg
}
// src/packages/actions.ts
import { ComparePackages } from "wasp/server/operations"
export const comparePackages: ComparePackages<{ names: string[] }, Package[]> = async (
{ names },
context
) => {
if (!context.user) throw new HttpError(401, "Not authenticated")
return context.entities.Package.findMany({
where: { name: { in: names } },
orderBy: { downloads: "desc" },
})
}
React frontend
// src/pages/Home.tsx
import { useQuery } from "wasp/client/operations"
import { getPackages } from "wasp/client/operations"
export function HomePage() {
const { data: packages, isLoading, error } = useQuery(getPackages)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>Top Packages</h1>
{packages?.map((pkg) => (
<div key={pkg.id}>
<h3>{pkg.name} v{pkg.version}</h3>
<p>{pkg.downloads.toLocaleString()} downloads</p>
</div>
))}
</div>
)
}
// src/pages/Compare.tsx
import { useAction, useQuery } from "wasp/client/operations"
import { comparePackages, getPackage } from "wasp/client/operations"
export function ComparePage() {
const compare = useAction(comparePackages)
const [selected, setSelected] = useState<string[]>([])
const handleCompare = async () => {
const results = await compare({ names: selected })
// Display comparison results
}
return (
<div>
<PackageSelector onSelect={setSelected} />
<button onClick={handleCompare}>Compare</button>
</div>
)
}
RedwoodJS
RedwoodJS — opinionated full-stack framework:
Project structure and schema
pkgpulse/
├── api/
│ ├── src/
│ │ ├── graphql/ # GraphQL schema
│ │ ├── services/ # Business logic
│ │ ├── lib/ # Shared utilities
│ │ └── functions/ # Serverless functions
│ └── db/
│ └── schema.prisma # Database schema
├── web/
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── layouts/ # Page layouts
│ │ ├── pages/ # Route pages
│ │ └── Routes.tsx # Routing
│ └── public/
└── redwood.toml
// api/db/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
plan String @default("free")
packages Package[]
}
model Package {
id Int @id @default(autoincrement())
name String @unique
description String?
downloads Int @default(0)
version String
tags Tag[]
userId Int
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
packages Package[]
}
GraphQL SDL and services
// api/src/graphql/packages.sdl.ts
export const schema = gql`
type Package {
id: Int!
name: String!
description: String
downloads: Int!
version: String!
tags: [Tag]!
user: User!
}
type Query {
packages(limit: Int): [Package!]! @skipAuth
package(name: String!): Package @skipAuth
searchPackages(query: String!): [Package!]! @skipAuth
}
type Mutation {
comparePackages(names: [String!]!): [Package!]! @requireAuth
updatePackage(name: String!, downloads: Int!, version: String!): Package! @requireAuth(roles: ["admin"])
}
`
// api/src/services/packages/packages.ts
import type { QueryResolvers, MutationResolvers } from "types/graphql"
import { db } from "src/lib/db"
export const packages: QueryResolvers["packages"] = ({ limit }) => {
return db.package.findMany({
orderBy: { downloads: "desc" },
take: limit || 50,
})
}
export const package_: QueryResolvers["package"] = ({ name }) => {
return db.package.findUnique({ where: { name } })
}
export const searchPackages: QueryResolvers["searchPackages"] = ({ query }) => {
return db.package.findMany({
where: {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
orderBy: { downloads: "desc" },
take: 20,
})
}
export const comparePackages: MutationResolvers["comparePackages"] = ({ names }) => {
return db.package.findMany({
where: { name: { in: names } },
orderBy: { downloads: "desc" },
})
}
// Field resolvers:
export const Package = {
tags: (_obj, { root }) => db.package.findUnique({ where: { id: root.id } }).tags(),
user: (_obj, { root }) => db.package.findUnique({ where: { id: root.id } }).user(),
}
Cells (data fetching pattern)
// web/src/components/PackagesCell/PackagesCell.tsx
import type { PackagesQuery } from "types/graphql"
import type { CellSuccessProps, CellFailureProps } from "@redwoodjs/web"
export const QUERY = gql`
query PackagesQuery($limit: Int) {
packages(limit: $limit) {
id
name
description
downloads
version
tags {
name
}
}
}
`
export const Loading = () => <div>Loading packages...</div>
export const Empty = () => <div>No packages found</div>
export const Failure = ({ error }: CellFailureProps) => (
<div>Error: {error.message}</div>
)
export const Success = ({ packages }: CellSuccessProps<PackagesQuery>) => (
<div className="grid gap-4">
{packages.map((pkg) => (
<div key={pkg.id} className="card">
<h3>{pkg.name} v{pkg.version}</h3>
<p>{pkg.downloads.toLocaleString()} downloads</p>
<div className="flex gap-1">
{pkg.tags.map((tag) => (
<span key={tag.name} className="badge">{tag.name}</span>
))}
</div>
</div>
))}
</div>
)
// Usage in a page:
// web/src/pages/HomePage/HomePage.tsx
import PackagesCell from "src/components/PackagesCell"
const HomePage = () => (
<div>
<h1>Top Packages</h1>
<PackagesCell limit={20} />
</div>
)
export default HomePage
Routing and auth
// web/src/Routes.tsx
import { Router, Route, Set, Private } from "@redwoodjs/router"
import MainLayout from "src/layouts/MainLayout"
const Routes = () => (
<Router>
<Set wrap={MainLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/packages/{name}" page={PackagePage} name="package" />
<Route path="/search" page={SearchPage} name="search" />
<Private unauthenticated="login">
<Route path="/compare" page={ComparePage} name="compare" />
<Route path="/dashboard" page={DashboardPage} name="dashboard" />
</Private>
<Route path="/login" page={LoginPage} name="login" />
<Route path="/signup" page={SignupPage} name="signup" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
// Auth setup (dbAuth, OAuth, etc.):
// api/src/lib/auth.ts
import { AuthenticationError } from "@redwoodjs/graphql-server"
import { db } from "./db"
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true, email: true, name: true, plan: true },
})
}
export const isAuthenticated = () => !!context.currentUser
export const requireAuth = ({ roles } = {}) => {
if (!isAuthenticated()) throw new AuthenticationError("Not authenticated")
if (roles && !roles.includes(context.currentUser.plan)) {
throw new AuthenticationError("Unauthorized")
}
}
BlitzJS
BlitzJS — full-stack toolkit for Next.js:
Setup and schema
// db/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
plan String @default("free")
sessions Session[]
tokens Token[]
packages Package[]
}
model Package {
id Int @id @default(autoincrement())
name String @unique
description String?
downloads Int @default(0)
version String
userId Int
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Server functions (zero-API RPC)
// src/packages/queries/getPackages.ts
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { z } from "zod"
const GetPackages = z.object({
limit: z.number().optional().default(50),
skip: z.number().optional().default(0),
})
export default resolver.pipe(
resolver.zod(GetPackages),
async ({ limit, skip }) => {
const packages = await db.package.findMany({
orderBy: { downloads: "desc" },
take: limit,
skip,
})
const count = await db.package.count()
return { packages, count, hasMore: skip + limit < count }
}
)
// src/packages/queries/getPackage.ts
import { resolver, NotFoundError } from "@blitzjs/rpc"
import db from "db"
import { z } from "zod"
const GetPackage = z.object({
name: z.string(),
})
export default resolver.pipe(
resolver.zod(GetPackage),
async ({ name }) => {
const pkg = await db.package.findUnique({ where: { name } })
if (!pkg) throw new NotFoundError()
return pkg
}
)
// src/packages/mutations/comparePackages.ts
import { resolver } from "@blitzjs/rpc"
import { Ctx } from "blitz"
import db from "db"
import { z } from "zod"
const ComparePackages = z.object({
names: z.array(z.string()).min(2).max(5),
})
export default resolver.pipe(
resolver.zod(ComparePackages),
resolver.authorize(), // Requires auth
async ({ names }, ctx: Ctx) => {
return db.package.findMany({
where: { name: { in: names } },
orderBy: { downloads: "desc" },
})
}
)
React pages (zero-API data fetching)
// src/pages/index.tsx
import { useQuery } from "@blitzjs/rpc"
import getPackages from "src/packages/queries/getPackages"
import Layout from "src/core/layouts/Layout"
function HomePage() {
// Direct function call — no API layer:
const [{ packages, hasMore }] = useQuery(getPackages, { limit: 20 })
return (
<Layout title="PkgPulse">
<h1>Top Packages</h1>
<div className="grid gap-4">
{packages.map((pkg) => (
<div key={pkg.id} className="card">
<h3>{pkg.name} v{pkg.version}</h3>
<p>{pkg.downloads.toLocaleString()} downloads</p>
</div>
))}
</div>
</Layout>
)
}
export default HomePage
// src/pages/compare.tsx
import { useMutation, useQuery } from "@blitzjs/rpc"
import comparePackages from "src/packages/mutations/comparePackages"
import { useAuthorize } from "@blitzjs/auth"
function ComparePage() {
useAuthorize() // Redirect to login if not authenticated
const [compare] = useMutation(comparePackages)
const [results, setResults] = useState<Package[]>([])
const handleCompare = async (names: string[]) => {
const packages = await compare({ names })
setResults(packages)
}
return (
<Layout title="Compare">
<PackageSelector onCompare={handleCompare} />
<ComparisonTable packages={results} />
</Layout>
)
}
export default ComparePage
Auth and middleware
// src/blitz-server.ts
import { setupBlitzServer } from "@blitzjs/next"
import { AuthServerPlugin, PrismaStorage } from "@blitzjs/auth"
import db from "db"
export const { gSSP, gSP, api } = setupBlitzServer({
plugins: [
AuthServerPlugin({
cookiePrefix: "pkgpulse",
storage: PrismaStorage(db),
isAuthorized: ({ ctx, args: [requiredPlan] }) => {
if (!ctx.session.userId) return false
if (requiredPlan && ctx.session.plan !== requiredPlan) return false
return true
},
}),
],
})
// Auth mutations (built-in):
// src/auth/mutations/signup.ts
import { SecurePassword } from "@blitzjs/auth/secure-password"
import db from "db"
import { z } from "zod"
const Signup = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional(),
})
export default async function signup(input: z.infer<typeof Signup>, ctx: any) {
const { email, password, name } = Signup.parse(input)
const hashedPassword = await SecurePassword.hash(password.trim())
const user = await db.user.create({
data: { email, hashedPassword, name, plan: "free" },
})
await ctx.session.$create({ userId: user.id, plan: user.plan })
return user
}
// Scaffolding CLI:
// blitz generate all package name:string downloads:int version:string
// → Creates model, queries, mutations, pages, forms
Feature Comparison
| Feature | Wasp | RedwoodJS | BlitzJS |
|---|---|---|---|
| Architecture | DSL + codegen | GraphQL full-stack | Next.js + RPC |
| Frontend | React (Vite) | React (Vite) | Next.js |
| Backend | Node.js (Express) | Node.js (Fastify) | Next.js API |
| Database | Prisma | Prisma | Prisma |
| API layer | Auto-generated | GraphQL | Zero-API (RPC) |
| Auth | ✅ (built-in) | ✅ (dbAuth, OAuth) | ✅ (built-in) |
| Config language | Wasp DSL | JavaScript | TypeScript |
| Code generation | ✅ (from DSL) | ✅ (generators) | ✅ (scaffolding) |
| Background jobs | ✅ (built-in) | ❌ (bring your own) | ❌ (bring your own) |
| ✅ (built-in) | ❌ | ❌ | |
| Type safety | ✅ (end-to-end) | ✅ (GraphQL codegen) | ✅ (end-to-end) |
| SSR/SSG | ❌ (SPA) | ✅ (prerender) | ✅ (Next.js) |
| Deploy | ✅ (one command) | ✅ (multiple targets) | ✅ (Vercel, etc.) |
| Testing | Vitest | Jest | Jest/Vitest |
| Maturity | Growing | Mature | Mature |
| Free/OSS | ✅ (MIT) | ✅ (MIT) | ✅ (MIT) |
Full-Stack Framework Trade-offs
Full-stack frameworks trade flexibility for productivity. By adopting Wasp, RedwoodJS, or BlitzJS, you commit to their conventions for routing, data fetching, authentication, and deployment — conventions that eliminate decision fatigue during initial development but constrain architectural choices as the application grows. The Wasp DSL is particularly opinionated: the declarative configuration model is powerful for the use cases it supports but requires workarounds for patterns it doesn't anticipate. RedwoodJS's Cells pattern is elegant for GraphQL data fetching but adds boilerplate when simple REST endpoint calls would be more appropriate. BlitzJS's zero-API layer is compelling until you need to expose a public API to third-party clients, at which point you must add a conventional REST or GraphQL API layer alongside the RPC layer.
Production Deployment and Build Considerations
Wasp's single-command deployment (wasp deploy fly) abstracts the entire deployment pipeline behind the DSL, which is fast for simple applications but limits control over the infrastructure. Wasp generates a Dockerfile and fly.toml during deployment that you can inspect and modify, but changes to these files may be overwritten on the next deploy cycle. RedwoodJS deploys as two separate services — the API (Fastify + GraphQL) and the web (static React) — which enables independent scaling and deployment. This split deploy model means your API can be updated without redeploying the frontend, which is valuable for API-heavy applications with frequent data model changes. BlitzJS on Next.js deploys as a single unified artifact to Vercel, Netlify, or any Node.js host, with the zero-API layer handled at build time through Babel transforms rather than runtime proxying.
TypeScript Integration and Type Generation
Type safety across the full stack is a defining feature of all three frameworks, but the mechanisms differ significantly. Wasp generates TypeScript types for queries and actions from the DSL definitions, providing compile-time errors when a server query's return type doesn't match the client's expected type. RedwoodJS uses GraphQL Code Generator to produce types from the GraphQL SDL files — graphql-codegen runs automatically during development to keep types synchronized with the schema. The generated types/graphql.ts file exports both query/mutation types and resolver types, enabling type-safe resolvers and cells simultaneously. BlitzJS's resolver pattern with Zod schemas provides runtime validation and TypeScript inference in the same declaration — resolver.zod(Schema) validates the input and infers the TypeScript type that flows through to the resolver function.
Database Migration Strategies
All three frameworks use Prisma for database access, inheriting its migration workflow. Prisma Migrate generates SQL migration files from schema changes, which must be committed to version control and applied to production databases during deployment. RedwoodJS wraps Prisma CLI commands in yarn rw prisma migrate deploy for production and yarn rw prisma migrate dev for development. Wasp automatically runs pending migrations during the deployment process. BlitzJS follows standard Prisma CLI conventions. The important production consideration is migration timing relative to deployment: always run migrations before deploying the new application version when adding columns, and after deploying when removing columns — this sequence ensures backward compatibility between the database schema and application code during rolling deployments.
Community Maturity and Long-Term Support
BlitzJS's development velocity has slowed significantly since its peak — the maintainer team announced a pivot away from the full framework toward blitz-auth as a standalone authentication package, with the full BlitzJS toolkit entering maintenance mode. Teams starting new projects in 2026 should evaluate this trajectory carefully. RedwoodJS has the strongest institutional backing with Tom Preston-Werner (GitHub co-founder) as a primary contributor, a dedicated core team, and an established enterprise customer base. Wasp is venture-backed and actively funded through Y Combinator, with a growing user base and rapid feature development — the DSL approach is both its strongest differentiator and its biggest adoption risk, as teams must commit to Wasp's abstraction model rather than composing standard tools.
Testing Approaches
RedwoodJS has the most comprehensive testing infrastructure of the three: Jest with Storybook for component testing, @redwoodjs/testing for service and cell testing with Prisma mocking, and API integration tests using @redwoodjs/api-testing. The Cells pattern makes UI testing particularly clean — the Success, Loading, Empty, and Failure exports are individually testable components. Wasp tests are standard React and Node.js tests without framework-specific testing utilities, relying on Vitest and Testing Library directly. BlitzJS testing follows Next.js conventions, using Jest and Testing Library for pages and components, with Blitz's createMockContext helper for testing mutations and queries that depend on the session context.
Error Handling and Observability Patterns
Full-stack frameworks abstract away the HTTP layer between client and server, which creates unique challenges for error handling and observability. In Wasp's RPC model, errors thrown in server operations propagate to the client as typed error objects — use Wasp's HttpError class to return structured errors with status codes that the client can pattern-match. RedwoodJS's GraphQL layer formats errors according to the GraphQL specification — unhandled errors become generic "Internal server error" messages in production, and you must use RedwoodJS's error handling utilities to surface meaningful error messages to clients. BlitzJS's zero-API mutations throw server-side errors directly as if they were client-side — use BlitzJS's AuthorizationError and NotFoundError classes for common cases, and catch unhandled errors in a global error boundary. For distributed tracing, all three frameworks run in Node.js processes, so OpenTelemetry's Node.js SDK can instrument them for trace propagation — configure the OTLP exporter to send traces to your preferred observability backend.
When to Use Each
Use Wasp if:
- Want the fastest path from idea to deployed app
- Prefer declarative configuration over imperative code
- Need built-in auth, jobs, and email without extra setup
- Building MVPs and prototypes quickly
Use RedwoodJS if:
- Want a structured, opinionated full-stack architecture
- Prefer GraphQL for your API layer
- Need Cells pattern for clean data fetching
- Building production apps with team conventions
Use BlitzJS if:
- Already using or want to use Next.js
- Want zero-API data layer (call server functions directly)
- Need SSR/SSG alongside full-stack features
- Prefer the Next.js ecosystem with full-stack extensions
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on Wasp v0.14.x, RedwoodJS v7.x, and BlitzJS v2.x.
Compare full-stack frameworks and developer tooling on PkgPulse →
See also: Lit vs Svelte and AVA vs Jest, acorn vs @babel/parser vs espree.