Skip to content

Fix: Wasp Not Working — Build Failing, Auth Not Working, or Operations Returning Empty

FixDevs · (Updated: )

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 failed

Or queries return empty results:

const { data: tasks } = useQuery(getTasks);
// data is undefined or empty array

Or authentication doesn’t redirect after login:

Login succeeds but user stays on the login page

Why This Happens

Wasp is a full-stack framework that uses a declarative DSL (.wasp file) alongside React and Node.js code:

  • main.wasp is 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 .wasp file stop everything.
  • Entities are Prisma models — Wasp uses Prisma under the hood. Entity definitions in the .wasp file map to Prisma models. Database operations use the Prisma client.
  • Operations have server-side implementations — queries and actions declared in .wasp point to TypeScript functions in src/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 server

Still 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 looponAuthSucceededRedirectTo 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 schedulePgBoss 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles