Wasp vs RedwoodJS vs BlitzJS: Full-Stack JavaScript Meta-Frameworks (2026)
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) |
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 →