Fix: Wasp Not Working — Build Failing, Auth Not Working, or Operations Returning Empty
Part of: React & Frontend Errors
Quick Answer
How to fix Wasp full-stack framework issues — main.wasp configuration, queries and actions, authentication setup, Prisma integration, job scheduling, and deployment.
The Problem
wasp start fails with a configuration error:
wasp start
# Error: Invalid Wasp configuration — or —
# Error: Prisma schema validation failedOr queries return empty results:
const { data: tasks } = useQuery(getTasks);
// data is undefined or empty arrayOr authentication doesn’t redirect after login:
Login succeeds but user stays on the login pageWhy This Happens
Wasp is a full-stack framework that uses a declarative DSL (.wasp file) alongside React and Node.js code:
main.waspis the source of truth — it declares routes, operations (queries/actions), auth, jobs, and entities. The framework generates boilerplate code from this file. Syntax errors in the.waspfile stop everything.- Entities are Prisma models — Wasp uses Prisma under the hood. Entity definitions in the
.waspfile map to Prisma models. Database operations use the Prisma client. - Operations have server-side implementations — queries and actions declared in
.wasppoint to TypeScript functions insrc/server/. If the import path is wrong or the function doesn’t exist, operations silently fail. - Auth is declarative — authentication providers (email/password, Google, GitHub) are configured in
.wasp. The actual auth flow is generated — you don’t write login logic manually.
What makes Wasp different from a regular Node.js project is the code generation step. When you run wasp start, the CLI parses main.wasp, generates a React + Express + Prisma project into .wasp/out/, and then runs that generated project. Your hand-written files in src/client/ and src/server/ are imported by the generated code. This means a typo in main.wasp — wrong import path, missing entity reference, mismatched type — causes the generated code to fail, often with stack traces that point into .wasp/out/. Those traces look unrelated to your code, which makes debugging confusing the first time it happens.
The second non-obvious behavior is that the generated client and server share types through the operation declarations. When you write query getTasks in main.wasp and point it at a TypeScript function, Wasp creates a typed RPC contract on both sides. If you change the function’s signature without updating the .wasp declaration’s entities list, the server still compiles but context.entities.X is undefined at runtime, and queries return without error — just empty. Always re-run wasp start after editing either the DSL or the operation implementation so the generated bridge code stays in sync.
Fix 1: Basic Wasp Configuration
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
wasp new my-app
cd my-app
wasp start// main.wasp — declarative configuration
app MyApp {
wasp: { version: "^0.14.0" },
title: "My App",
head: [
"<meta name='description' content='My Wasp App' />"
],
auth: {
userEntity: User,
methods: {
usernameAndPassword: {},
google: {},
github: {},
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/dashboard",
},
db: {
system: PostgreSQL,
// seeds: [import { seedDevData } from "@src/server/seeds"]
},
}
// Entities (Prisma models)
entity User {=psl
id Int @id @default(autoincrement())
tasks Task[]
createdAt DateTime @default(now())
psl=}
entity Task {=psl
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
psl=}
// Routes
route RootRoute { path: "/", to: MainPage }
route LoginRoute { path: "/login", to: LoginPage }
route SignupRoute { path: "/signup", to: SignupPage }
route DashboardRoute { path: "/dashboard", to: DashboardPage }
// Pages
page MainPage {
component: import { MainPage } from "@src/client/MainPage"
}
page LoginPage {
component: import { LoginPage } from "@src/client/LoginPage"
}
page SignupPage {
component: import { SignupPage } from "@src/client/SignupPage"
}
page DashboardPage {
authRequired: true,
component: import { DashboardPage } from "@src/client/DashboardPage"
}
// Queries (read operations)
query getTasks {
fn: import { getTasks } from "@src/server/queries",
entities: [Task]
}
query getTask {
fn: import { getTask } from "@src/server/queries",
entities: [Task]
}
// Actions (write operations)
action createTask {
fn: import { createTask } from "@src/server/actions",
entities: [Task]
}
action updateTask {
fn: import { updateTask } from "@src/server/actions",
entities: [Task]
}
action deleteTask {
fn: import { deleteTask } from "@src/server/actions",
entities: [Task]
}How Other Tools Handle This
The “single config file describes the whole app” idea isn’t unique to Wasp, but the trade-offs across competing full-stack frameworks are very different.
Redwood is the closest philosophical neighbor. It scaffolds a React + GraphQL + Prisma project with a strong convention layer, but the configuration is split across redwood.toml, schema.prisma, and SDL files instead of a single DSL. Redwood gives you per-route code splitting and an opinionated test setup out of the box; Wasp gives you a higher level of abstraction (auth and jobs declared in three lines) at the cost of GraphQL flexibility — Wasp uses simple JSON RPC, not a query language.
Blitz.js (now a Next.js toolkit rather than a standalone framework) introduced the idea of “zero-API” — calling server functions directly from React components with full type safety. Wasp’s operations are conceptually the same, but Wasp wraps them in a code generation step instead of a Babel/SWC transform. Blitz pairs better with the rest of the Next.js ecosystem; Wasp’s DSL makes the auth and jobs story dramatically simpler.
Remix + Prisma is the manual alternative. You write routes, loaders, actions, and a Prisma schema yourself. There’s no DSL, no codegen, no magic — every piece is normal Node.js you can debug directly. The win is full control and a smaller dependency graph; the cost is implementing auth, background jobs, and email flows from scratch. Wasp gives you those out of the box but locks you into its DSL versioning.
Auth abstraction is where Wasp shines hardest. Adding Google login is methods: { google: {} } plus an environment variable. In Remix you’d install remix-auth, write the OAuth callback by hand, manage sessions, and handle the redirect dance. In Redwood you’d configure dbAuth or a third-party provider through setup auth. Wasp’s declarative approach trades flexibility (you can’t customize the auth UI as deeply) for development speed.
Deployment workflows also differ. Wasp ships wasp deploy fly, which provisions a Postgres database, sets secrets, and deploys both client and server with one command. Redwood relies on per-target deploy commands (yarn rw deploy netlify, vercel, aws-serverless). Remix is “deploy anywhere” with adapter-specific config. If you want a single command from wasp new to a public URL with auth and a database, Wasp is the shortest path.
For framework-adjacent failures, see Fix: Prisma Migration Failed (the database half of every full-stack framework) and Fix: tRPC Not Working (the type-safe RPC pattern Wasp’s operations are modeled after).
Fix 2: Server Operations
// src/server/queries.ts
import { type GetTasks, type GetTask } from 'wasp/server/operations';
export const getTasks: GetTasks<void, Task[]> = async (args, context) => {
if (!context.user) throw new HttpError(401, 'Not authenticated');
return context.entities.Task.findMany({
where: { userId: context.user.id },
orderBy: { createdAt: 'desc' },
});
};
export const getTask: GetTask<{ id: number }, Task> = async ({ id }, context) => {
if (!context.user) throw new HttpError(401);
const task = await context.entities.Task.findUnique({ where: { id } });
if (!task || task.userId !== context.user.id) {
throw new HttpError(404, 'Task not found');
}
return task;
};// src/server/actions.ts
import { type CreateTask, type UpdateTask, type DeleteTask } from 'wasp/server/operations';
import { HttpError } from 'wasp/server';
export const createTask: CreateTask<{ description: string }, Task> = async ({ description }, context) => {
if (!context.user) throw new HttpError(401);
return context.entities.Task.create({
data: {
description,
userId: context.user.id,
},
});
};
export const updateTask: UpdateTask<{ id: number; isDone: boolean }, Task> = async ({ id, isDone }, context) => {
if (!context.user) throw new HttpError(401);
const task = await context.entities.Task.findUnique({ where: { id } });
if (!task || task.userId !== context.user.id) {
throw new HttpError(404);
}
return context.entities.Task.update({
where: { id },
data: { isDone },
});
};
export const deleteTask: DeleteTask<{ id: number }, void> = async ({ id }, context) => {
if (!context.user) throw new HttpError(401);
await context.entities.Task.delete({ where: { id } });
};Fix 3: Client Pages
// src/client/DashboardPage.tsx
import { useQuery, useAction } from 'wasp/client/operations';
import { getTasks, createTask, updateTask, deleteTask } from 'wasp/client/operations';
import { useState } from 'react';
export function DashboardPage() {
const { data: tasks, isLoading, error } = useQuery(getTasks);
const createTaskFn = useAction(createTask);
const updateTaskFn = useAction(updateTask);
const deleteTaskFn = useAction(deleteTask);
const [newTask, setNewTask] = useState('');
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newTask.trim()) return;
await createTaskFn({ description: newTask });
setNewTask('');
// Wasp automatically invalidates the getTasks query
}
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>Dashboard</h1>
<form onSubmit={handleCreate}>
<input
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="New task..."
/>
<button type="submit">Add</button>
</form>
<ul>
{tasks?.map(task => (
<li key={task.id}>
<input
type="checkbox"
checked={task.isDone}
onChange={() => updateTaskFn({ id: task.id, isDone: !task.isDone })}
/>
<span style={{ textDecoration: task.isDone ? 'line-through' : 'none' }}>
{task.description}
</span>
<button onClick={() => deleteTaskFn({ id: task.id })}>Delete</button>
</li>
))}
</ul>
</div>
);
}Fix 4: Authentication Pages
// src/client/LoginPage.tsx
import { LoginForm } from 'wasp/client/auth';
import { Link } from 'wasp/client/router';
export function LoginPage() {
return (
<div style={{ maxWidth: 400, margin: '0 auto', padding: 20 }}>
<h1>Login</h1>
<LoginForm />
<p>
Don't have an account? <Link to="/signup">Sign up</Link>
</p>
</div>
);
}
// src/client/SignupPage.tsx
import { SignupForm } from 'wasp/client/auth';
import { Link } from 'wasp/client/router';
export function SignupPage() {
return (
<div style={{ maxWidth: 400, margin: '0 auto', padding: 20 }}>
<h1>Sign Up</h1>
<SignupForm />
<p>
Already have an account? <Link to="/login">Log in</Link>
</p>
</div>
);
}
// Access user in any component
import { useAuth } from 'wasp/client/auth';
function UserMenu() {
const { data: user } = useAuth();
if (!user) return <Link to="/login">Log in</Link>;
return (
<div>
<span>Welcome, {user.identities.username?.id || user.id}</span>
<button onClick={() => window.location.href = '/auth/logout'}>Logout</button>
</div>
);
}Fix 5: Background Jobs
// main.wasp — declare a job
job dailyReport {
executor: PgBoss,
perform: {
fn: import { generateDailyReport } from "@src/server/jobs"
},
schedule: {
cron: "0 9 * * *" // Every day at 9 AM
},
entities: [Task, User]
}
job sendEmail {
executor: PgBoss,
perform: {
fn: import { sendEmail } from "@src/server/jobs"
},
entities: [User]
}// src/server/jobs.ts
import { type DailyReport, type SendEmail } from 'wasp/server/jobs';
export const generateDailyReport: DailyReport<void, void> = async (args, context) => {
const users = await context.entities.User.findMany({
include: { tasks: true },
});
for (const user of users) {
const completedToday = user.tasks.filter(t =>
t.isDone && isToday(t.createdAt)
).length;
await sendReportEmail(user, completedToday);
}
};
export const sendEmail: SendEmail<{ to: string; subject: string; html: string }, void> = async ({ to, subject, html }) => {
await emailProvider.send({ to, subject, html });
};
// Trigger a job manually from an action
import { sendEmail } from 'wasp/server/jobs';
export const inviteUser = async ({ email }, context) => {
await sendEmail.submit({
to: email,
subject: 'You are invited!',
html: '<p>Welcome!</p>',
});
};Fix 6: Deployment
# Deploy to Fly.io
wasp deploy fly setup
wasp deploy fly cmd secrets set DATABASE_URL=postgres://...
wasp deploy fly deploy
# Or build for self-hosting
wasp build
# Output in .wasp/build/
# Contains Dockerfile and docker-compose for both client and serverStill Not Working?
Wasp configuration error — check main.wasp syntax. Common issues: missing commas in entity definitions, wrong import paths (must use @src/ prefix), or referencing entities not declared in the operation’s entities list.
Queries return empty — the operation needs entities: [Task] in the .wasp declaration to access context.entities.Task. Without it, the Prisma client for that entity isn’t available. Also check auth — context.user is null for unauthenticated requests.
Auth redirect loop — onAuthSucceededRedirectTo in the auth config must point to a route that exists and doesn’t require auth itself. If the redirect target also requires auth, you get a loop.
Database errors after changing entities — run wasp db migrate-dev after modifying entity definitions. Wasp uses Prisma migrations. Without migrating, the database schema doesn’t match the code.
Generated code is stale after editing main.wasp — Wasp regenerates .wasp/out/ on save, but file watchers occasionally miss changes (especially on Windows or under WSL). Stop the dev server, run wasp clean, then wasp start again. If the issue persists, delete .wasp/out/ manually before restarting.
Jobs don’t run on schedule — PgBoss needs a Postgres database with the pgboss extension created in the same connection. SQLite cannot run PgBoss jobs. Verify DATABASE_URL points to Postgres and that the user has permission to create schemas, then check the pgboss.job table directly to see whether jobs are being enqueued.
Type errors in generated code — when you add an entity to an operation’s entities: [...] list, Wasp regenerates the context.entities type. If your IDE caches the old type, restart the TypeScript server (Ctrl+Shift+P → TypeScript: Restart TS Server) so editor types match the generated ones.
For related full-stack issues, see Fix: TanStack Start Not Working and Fix: SvelteKit Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: TanStack Start Not Working — Server Functions Failing, Routes Not Loading, or SSR Errors
How to fix TanStack Start issues — project setup, file-based routing, server functions with createServerFn, data loading, middleware, SSR hydration, and deployment configuration.
Fix: Waku Not Working — Server Components Not Rendering, Client Components Not Interactive, or Build Errors
How to fix Waku React framework issues — RSC setup, server and client component patterns, data fetching, routing, layout system, and deployment configuration.
Fix: Next.js Server Action Not Working — Action Not Called or Returns Error
How to fix Next.js Server Actions — use server directive, form binding, revalidation, error handling, middleware conflicts, and client component limitations.
Fix: Analog Not Working — Routes Not Loading, API Endpoints Failing, or Vite Build Errors
How to fix Analog (Angular meta-framework) issues — file-based routing, API routes with Nitro, content collections, server-side rendering, markdown pages, and deployment.