Skip to main content

Guide

Wasp vs RedwoodJS vs BlitzJS (2026)

Compare Wasp, RedwoodJS, and BlitzJS for full-stack JavaScript development. Code generation, auth, database, deployment, and which full-stack framework to.

·PkgPulse Team·
0

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

FeatureWaspRedwoodJSBlitzJS
ArchitectureDSL + codegenGraphQL full-stackNext.js + RPC
FrontendReact (Vite)React (Vite)Next.js
BackendNode.js (Express)Node.js (Fastify)Next.js API
DatabasePrismaPrismaPrisma
API layerAuto-generatedGraphQLZero-API (RPC)
Auth✅ (built-in)✅ (dbAuth, OAuth)✅ (built-in)
Config languageWasp DSLJavaScriptTypeScript
Code generation✅ (from DSL)✅ (generators)✅ (scaffolding)
Background jobs✅ (built-in)❌ (bring your own)❌ (bring your own)
Email✅ (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.)
TestingVitestJestJest/Vitest
MaturityGrowingMatureMature
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.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.