How to Build a Full-Stack Authenticated App in 30 Minutes with Wasp
Stop wrestling with boilerplate code. This hands-on tutorial shows you how to use Wasp—a declarative full-stack framework—to rapidly build a TodoList app with user authentication, database modeling, and type-safe RPC communication, all in under 30 minutes.

How to Build a Full-Stack Authenticated App in 30 Minutes with Wasp
Honestly, every time I get a request to "build an admin dashboard" or "spin up an internal tool," I dread it—not because the tech is hard, but because the setup is fragmented and tedious.
You start a React frontend, wire up an Express backend, set up Prisma for the database, manually implement JWT auth, configure CORS, and write glue code to align frontend and backend types... Before you even write a single line of business logic, you've already spent a day scaffolding. Don't even get me started on Dockerfiles and CI/CD pipelines.
If you've ever felt the pain of "spending 80% of the time configuring environments and only 20% writing actual features," this tutorial is for you.
We'll use Wasp, a declarative full-stack framework, to build a complete TodoList application from scratch. It will include: user registration/login authentication, database modeling, frontend-to-backend RPC communication, and dynamic data rendering. You won't need to manually write Express routes, handle CORS, or define API type interfaces—Wasp generates everything for you.
By the end of this guide, you'll have a deployable full-stack app skeleton that will cut your development time in half for future projects.
Prerequisites
Before we begin, ensure your environment meets the following requirements:
- Node.js >= 18.0 (recommended to manage versions via
nvm) - npm or yarn package manager
- SQLite or PostgreSQL (we'll use SQLite by default for zero-config local development)
- Basic familiarity with React components and TypeScript typing
- OS: macOS, Linux, or Windows (WSL) all work fine
Why Node 18+? Wasp relies on modern Node.js APIs under the hood. Older versions will cause the CLI tool to fail. Upgrade first to avoid unnecessary debugging later.
Step 1: Install the Wasp CLI
Wasp's core is a compiler: you write declarative configurations and business logic, and it scaffolds the complete full-stack project for you. Start by installing the CLI:
bash
npm install -g @wasp.sh/wasp-cli@latest
Verify the installation:
bash
wasp --version
If you see a version number, you're good to go. We install it globally so the wasp command is accessible from any subdirectory in your project workflow.
Step 2: Initialize Your Project
Wasp provides an out-of-the-box project template. Generate your project skeleton with:
bash
wasp new todo-app
cd todo-app
After running this, you'll get a clean, well-structured directory. The most important file here is main.wasp (or main.wasp.ts)—this is Wasp's declarative core. All routes, pages, operations, and authentication configurations are centralized here.
Why keep React and Express files separate? Wasp enforces separation of concerns: declarative files define the architecture, while business files contain the logic. The benefit? When you need a new page or endpoint, you just update the declarative file, and Wasp automatically handles the underlying wiring.
Step 3: Define Your Database Schema
Wasp integrates Prisma as its ORM, so database models are defined in schema.prisma. Open db/schema.prisma in your project and update it with the following:
prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
tasks Task[]
}
model Task {
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
user User @relation(fields: [userId], references: [id])
userId Int
}
This does two things: defines User and Task tables and establishes a foreign key relationship between them. I chose SQLite because it's zero-config and perfect for local development. For production, simply change the provider to postgresql, update the connection string, and you're done—your business code won't need a single change. This is the power of Prisma, which Wasp leverages natively.
After updating the schema, run the database migration:
bash
cd ext/db
npx prisma migrate dev
npx prisma generate
Step 4: Configure Declarative Architecture (main.wasp)
This is the heart of Wasp. Open main.wasp and paste the following configuration:
wasp
app todo-app {
wasp: { version: "^0.16.0" },
title: "Todo App"
}
route RootRoute { path: "/", to: MainPage }
page MainPage {
authRequired: true,
component: import MainPage from "@ext/MainPage"
}
query getTasks {
fn: import { getTasks } from "@ext/queries",
entities: [Task]
}
action createTask {
fn: import { createTask } from "@ext/actions",
entities: [Task]
}
auth {
userEntity: User,
methods: {
usernameAndPassword: {}
},
onAuthFailedRedirectTo: "/login"
}
authRoute LoginPage { path: "/login", to: LoginPageComponent }
page LoginPageComponent {
component: import { LoginPage } from "@wasp/auth"
}
What does this declarative code do?
- route maps URLs to pages.
- page declares the UI component.
authRequired: truemeans only authenticated users can access it—no custom middleware needed; Wasp handles it automatically. - query and action define frontend-to-backend RPC methods. The
entities: [Task]directive is crucial: it tells Wasp that this operation depends on theTaskentity. WhenTaskdata changes, Wasp automatically invalidates caches and refetches data. This keeps your UI perfectly in sync without manualrefetchcalls. - auth declares the authentication method. Wasp will generate the login page, registration flow, and session management out of the box—zero custom auth code required.
Step 5: Implement Frontend UI and Business Logic
Frontend UI: MainPage.tsx
Create src/MainPage.tsx in the project root:
tsx
import { useQuery, useAction } from "wasp/client";
import { getTasks } from "wasp/client/operations";
import { createTask } from "wasp/client/operations";
import { useState } from "react";
export default function MainPage() {
const { data: tasks, isLoading } = useQuery(getTasks);
const [description, setDescription] = useState("");
const handleCreate = useAction(createTask);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (description.trim()) {
await handleCreate({ description });
setDescription("");
}
};
if (isLoading) return <div>Loading...</div>;
return (
<div style={{ maxWidth: 400, margin: "40px auto", padding: 20 }}>
<h2>My Todo List</h2>
<form onSubmit={handleSubmit}>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="New task..."
style={{ width: "calc(100% - 80px)", padding: 8, marginRight: 8 }}
/>
<button type="submit" style={{ padding: "8px 16px" }}>Add</button>
</form>
<ul style={{ listStyle: "none", padding: 0, marginTop: 20 }}>
{tasks?.map((task) => (
<li key={task.id} style={{ padding: 8, borderBottom: "1px solid #eee" }}>
<span style={{ textDecoration: task.isDone ? "line-through" : "none" }}>
{task.description}
</span>
</li>
))}
</ul>
</div>
);
}
Notice the useQuery and useAction hooks. They are Wasp's abstractions over TanStack Query. Why not use fetch or axios directly? Because Wasp guarantees end-to-end type safety. The function signatures you define on the server automatically generate full TypeScript types on the client. Passing the wrong parameters? The compiler will catch it immediately, saving you from runtime bugs.
Backend Logic: Queries and Actions
Create src/queries.ts:
ts
import { Task } from "wasp/entities";
export const getTasks = async (_args: void, context: any): Promise<Task[]> => {
if (!context.user) throw new Error("Unauthorized");
return context.entities.Task.findMany({
where: { userId: context.user.id },
orderBy: { id: "desc" },
});
};
Create src/actions.ts:
ts
import { Task } from "wasp/entities";
export const createTask = async (
args: { description: string },
context: any
): Promise<Task> => {
if (!context.user) throw new Error("Unauthorized");
return context.entities.Task.create({
data: {
description: args.description,
userId: context.user.id,
},
});
};
The key here is context.user. Wasp automatically injects the authenticated user's information at runtime. You can use it directly in your queries and actions without manually parsing tokens from headers. This is a direct benefit of declarative authentication.
Step 6: Run and Verify Your Application
With everything in place, start the development server:
bash
wasp start
Wasp will automatically spin up both the frontend dev server and the backend server, opening your browser to the registration page.
Verification Steps:
- On first visit, you'll be redirected to
/login. Click "Sign up" to register a new account. - After logging in, you'll land on the main page. Enter a task description and click "Add".
- The new task will appear instantly in the list (no page refresh required).
- Close and reopen the browser, then navigate directly to
/. You'll be redirected to the login page again—Wasp manages authentication sessions automatically.
At this point, your full-stack app is running. Frontend-to-backend communication uses Wasp's auto-generated RPC layer, database queries run through Prisma, and auth is handled by built-in session management. You didn't write a single line of glue code.
Common Pitfalls & Troubleshooting
- CLI Version Mismatch: The
wasp: { version: "^0.16.0" }inmain.waspmust match your installed CLI's major version. If you get a version error, runnpm i -g @wasp.sh/wasp-cli@latest. - Prisma Migration Failures: Always run
npx prisma migrate devafter modifyingschema.prisma. If conflicts arise during local development, you can safely delete thedev.dbfile and start fresh. - TypeScript Errors: Wasp auto-generates type definitions in the
.wasp/out/sdkfolder upon startup. If your IDE shows red squiggles initially, wait forwasp startto fully initialize, then restart your TypeScript language server. This is normal and not a code issue. - Hot Reload Limits: Modifying
main.wasprequires restartingwasp start(or waiting for the Wasp compiler to auto-regenerate). Modifying.tsx/.tsbusiness files supports hot reloading out of the box.
Conclusion & Next Steps
Let's recap what we accomplished:
- Installed the Wasp CLI and generated a project skeleton with
wasp new. - Defined a database schema using Prisma and ran migrations.
- Configured routes, pages, RPC operations, and authentication declaratively in
main.wasp. - Wrote the frontend UI and backend query/action functions.
- Launched the server and verified the full user journey: registration, login, task creation, and data rendering.
Throughout this process, we never manually configured Express routes, wrote JWT middleware, handled CORS, or defined RPC interfaces. Wasp's declarative compiler handled it all, allowing you to focus purely on business logic.
Recommended Next Steps:
- Add
google: {}toauth.methodsinmain.waspto integrate Google OAuth. - Utilize Wasp's
emailfeatures to send notifications. - Implement a
deleteTaskaction for full CRUD functionality. - Deploy to Render or Fly.io using the
wasp deploycommand.
While Wasp is currently in beta, its core features are highly stable. If you're tired of scaffolding full-stack projects from scratch, this framework is definitely worth trying. Join the Wasp Discord community or open a GitHub issue if you run into questions.
Happy coding!