Skip to main content

Wasp vs RedwoodJS vs BlitzJS: Full-Stack JavaScript Meta-Frameworks (2026)

·PkgPulse Team

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)

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.