Build a Multi-Tenant Task Board

Build a full-stack task board with auth, GraphQL, and org-based data isolation.

Introduction

30 Second Summary

Think about how apps like Slack or Notion keep each company's data completely separate. Behind the scenes, a chain of technologies connects the database all the way to the buttons you click on screen.

In this project, you will build a multi-tenant task board from scratch. You will set up a database, auto-generate a GraphQL API, wire up a typed frontend, and then evolve the app when new requirements arrive.

What You'll Build

A full-stack task board application using Nhost, Hasura, GraphQL, Apollo Client, and Next.js, with organization-based data isolation so each team only sees its own tasks.

By the end of this project, you'll have:

  • A running Nhost stack (Postgres, Hasura, and Auth) orchestrated by Docker on your Mac.
  • A Next.js frontend with login, task list, task creation, and comments, powered by auto-generated GraphQL hooks with full TypeScript types.
  • A mental model of the full chain (Database > Hasura > GraphQL > Codegen > Hooks > Components > Screen) so you know exactly where to look when something breaks.
  • Secret Mission: Add drag-and-drop task priority reordering with optimistic UI updates.

Are there any prerequisites?

You should be comfortable with terminal basics (cd, running commands). You also need a Mac with Docker Desktop already installed and running.

Set Up Docker and Verify It Works

Your task board runs on three backend services: a Postgres database, a Hasura GraphQL engine, and an authentication server.

Instead of installing each of these directly on your Mac, Docker runs them as isolated containers. In this step, you will confirm Docker is ready to go before you start building.

In this step, get ready to:
  • Launch Docker Desktop and confirm it is running.
  • Run a test container to verify Docker can pull and run images.
  • Understand Docker's role in the full-stack chain.
Launch Docker Desktop

Docker Desktop needs to be actively running before you can use any Docker commands in the terminal. The Docker icon in your menu bar tells you its status.

  • Press Cmd+Space (macOS) to open your search bar.
  • Type Docker and press Enter to open Docker Desktop.
  • Wait for the Docker icon in your menu bar (top-right of your screen) to stop animating. A steady whale icon means Docker is ready.
  • Verify Docker is responsive by running this command in your terminal:
docker --version

You should see a version number printed, something like Docker version 27.x.x. The exact number does not matter as long as something appears.

Seeing 'command not found'?

This usually means Docker Desktop has not finished starting up. Give it another 30 seconds and try again.

If the error persists, open Docker Desktop manually from your Applications folder and wait until the dashboard loads before retrying.

Why can't my terminal find the docker command?

Run a test container

A version number confirms Docker is installed. Running a container confirms Docker can actually pull images from the internet and execute them.

  • Run the official Docker test image by running this command:
docker run hello-world

You should see a message that starts with Hello from Docker! followed by an explanation of what just happened. This confirms Docker can pull images and run containers successfully.

What just happened?

Docker pulled a tiny image called hello-world from Docker Hub (a public image registry). It then created a container from that image, ran it, printed the greeting, and stopped.

Later, when you run nhost up, it does the same thing but for Postgres, Hasura, and Auth. Each service runs in its own container, isolated from the others and from your Mac.

Seeing a permission error or timeout?

If you see a permissions error, try running the command with sudo docker run hello-world. If Docker Desktop asks for permissions in a system popup, click Allow.

If you see a network timeout, check your internet connection. Docker needs to download the image the first time you run this command.

Help me fix my Docker test container error.

Understand Docker's role in this project

Why does this project use Docker?

Your task board needs three backend services: Postgres (the database), Hasura (the GraphQL engine), and an authentication server. Installing and configuring each one directly on your Mac would take significant effort and create conflicts with other projects.

Docker lets you run all three as isolated containers with a single command. Each service gets its own environment, its own ports, and its own filesystem. When you are done, you stop the containers and your Mac is clean.

In the next step, you will install the Nhost CLI, which orchestrates all of these Docker containers for you automatically.

  • Also confirm Docker Compose is available by running:
docker compose version

You should see a version number like Docker Compose version v2.x.x. The Nhost CLI uses Docker Compose behind the scenes to coordinate all of your backend services.

Docker Compose not found?

Docker Compose comes bundled with Docker Desktop on macOS. If the command is not found, your Docker Desktop installation may be outdated. Open Docker Desktop, go to Settings, then Software updates, and install any available updates.

Why is docker compose not available on my Mac?

Docker is ready to go. Next up, you will install Node.js and the Nhost CLI so you can scaffold your project and spin up the full backend stack.

Install Node.js and the Nhost CLI

Docker is running and you have confirmed it works. Now you need two more tools before you can start building.

Node.js powers all the frontend tooling in this project, including Next.js and GraphQL Codegen. The Nhost CLI is the tool that orchestrates all the backend services (Postgres, Hasura, Auth) inside Docker for you.

In this step, get ready to:
  • Install Node.js.
  • Install the Nhost CLI.
  • Verify both tools respond to version checks.
Install Node.js

Node.js is the JavaScript runtime that makes Next.js, npm packages, and codegen work on your machine. You need version 20 or later.

  • Check if Node.js is already installed by running this command in your Terminal:
node -v

✔️ I see a version number

You are good to go. Make sure the version number starts with v20 or higher (e.g. v20.11.0 or v22.4.1).

  • Confirm npm is also available by running:
npm -v

You should see a version number like 10.x.x. npm comes bundled with Node.js, so if Node.js is installed, npm should be too.

ⓧ Command not found

Node.js is not installed yet. The recommended way to install it on macOS is with nvm (Node Version Manager), which lets you switch between versions easily.

  • Install nvm by running this command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
  • Close your Terminal window completely and open a new one.
  • Install the latest LTS version of Node.js by running:
nvm install --lts

This downloads and installs the latest stable Node.js version along with npm.

  • Verify the installation by running:
node -v

You should see a version number starting with v20 or higher.

Still seeing command not found after installing?

Make sure you opened a brand new Terminal window after installing nvm. The install script modifies your shell profile, and the old window does not pick up the changes.

If it still does not work, try running source ~/.zshrc to reload your shell configuration manually.

Help me troubleshoot my Node.js installation on macOS

What is nvm?

nvm (Node Version Manager) lets you install multiple versions of Node.js and switch between them per project. This is useful when different projects require different Node.js versions.

Install the Nhost CLI

The Nhost CLI is what ties together all the backend services you saw Docker can run. When you run nhost up later, it starts Postgres, Hasura, and Auth as Docker containers, all pre-configured to talk to each other.

  • Install the Nhost CLI by running this command:
sudo curl -L https://raw.githubusercontent.com/nhost/cli/main/get.sh | bash

What does this command do?

This downloads the Nhost CLI install script from GitHub and runs it. sudo is needed because the binary gets placed in a system directory.

Your Mac will ask for your password. Type it and press Enter. You will not see characters appear as you type. That is normal.

  • Verify the Nhost CLI installed correctly by running:
nhost --version

You should see a version number printed in the terminal (e.g. v1.31.3 or newer).

Seeing permission denied or command not found?

If you see permission denied, make sure you included sudo at the start of the command.

If you see command not found after installation, close your Terminal and open a new window. The CLI may have been added to a path that your current session has not loaded yet.

Help me troubleshoot my Nhost CLI installation

Why Nhost instead of bare Hasura and Postgres?

You could install Postgres, Hasura, and an Auth service separately and wire them together yourself. Nhost bundles all three and handles the connections for you.

This means you spend your time learning schema design and frontend patterns instead of debugging Docker networking between services.

Your development environment is fully ready. Next up, you will plan the database schema for your task board on paper before touching any tool.

Plan the Initial Schema

Your tools are installed and ready. Now comes the most important part of any full-stack project: deciding what your data looks like before you start building.

In this step, you'll design the database schema for your multi-tenant task board entirely on paper (or a notes app). The goal is to think through every table, column, and relationship so that when you open the Nhost Dashboard in the next step, you already know exactly what to create.

In this step, get ready to:
  • Identify the entities your task board needs.
  • Map out the relationships between tables and identify the multi-tenancy mechanism.
  • Write down the complete schema plan with columns, types, and foreign keys.
Think through what your task board needs

A multi-tenant task board means multiple organizations share the same application, but each one only sees its own data. To make that work, you need to answer three questions:

  • What groups of data exist? (These become your tables.)
  • How do they connect to each other? (These become your foreign keys.)
  • How do you prevent one organization from seeing another's data? (This is the multi-tenancy mechanism.)

Start by thinking about the core entities. Open a notes app, a piece of paper, or a blank file in VS Code. Write down the three things your task board needs to track:

  • Write down Organizations as your first entity. This is the container that everything else belongs to. Each organization is a team or company using your app.
  • Write down Tasks as your second entity. Tasks are the core data. Each task belongs to one organization.
  • Write down User-Organization memberships as your third entity. This links users to the organizations they belong to.

Why not a users table?

Nhost already provides a built-in auth.users table that handles authentication (email, password, sessions). You don't create your own users table from scratch.

Instead, you create a user_organizations table that references Nhost's auth.users and connects each user to the organization they belong to.

Map out relationships and identify multi-tenancy

Now that you have your three entities, draw arrows between them to show how they connect. Each arrow represents a foreign key. A foreign key is a column in one table that points to a row in another table.

  • Draw an arrow from tasks to organizations. Label it organization_id. This means every task belongs to exactly one organization.
  • Draw an arrow from tasks to auth.users. Label it created_by. This tracks which user created the task.
  • Draw an arrow from user_organizations to auth.users. Label it user_id. This connects a membership row to a specific user.
  • Draw an arrow from user_organizations to organizations. Label it organization_id. This connects a membership row to a specific organization.

What makes this multi-tenant?

The organization_id column on the tasks table is the key. When a user requests their tasks, Hasura checks which organization the user belongs to and only returns rows where organization_id matches.

Every single data row is scoped to an organization. Without that column, there would be no way to separate one team's tasks from another's.

Write a note next to your diagram: "Multi-tenancy mechanism: filter all queries by organization_id."

Write down the complete schema with columns and types

Now flesh out each table with its specific columns. For each column, note the data type and whether it's a primary key or foreign key.

  • Under your organizations entity, write these columns:
organizations
  - id: uuid (Primary Key)
  - name: text
  - created_at: timestamptz
  • Under your tasks entity, write these columns:
tasks
  - id: uuid (Primary Key)
  - title: text
  - description: text
  - status: text (default: 'todo')
  - organization_id: uuid (Foreign Key > organizations.id)
  - created_by: uuid (Foreign Key > auth.users.id)
  - created_at: timestamptz
  • Under your user_organizations entity, write these columns:
user_organizations
  - id: uuid (Primary Key)
  - user_id: uuid (Foreign Key > auth.users.id)
  - organization_id: uuid (Foreign Key > organizations.id)

Why uuid for every primary key?

UUIDs are globally unique identifiers that look like a1b2c3d4-e5f6-7890-abcd-ef1234567890. Nhost uses them by default because they're safe for distributed systems. No two rows will ever accidentally share an ID, even across tables.

Why a separate user_organizations table?

You could put an organization_id directly on the user. A join table gives you more flexibility. If you later want a user to belong to multiple organizations, the schema already supports it without a migration.

Review your final schema plan. You should have three tables, each with typed columns, and clear labels showing which columns are primary keys and which are foreign keys pointing to other tables.

✔️ Awesome, I've got my schema plan!

Great. Double check that your plan includes all three tables with their columns and relationships marked. Save your notes. You'll reference this plan directly in the next step.

ⓧ I'd like to double check the full schema

Here's the complete schema plan. Make sure yours matches:

=== SCHEMA PLAN ===

organizations
  - id: uuid (Primary Key)
  - name: text
  - created_at: timestamptz

tasks
  - id: uuid (Primary Key)
  - title: text
  - description: text
  - status: text (default: 'todo')
  - organization_id: uuid (Foreign Key > organizations.id)
  - created_by: uuid (Foreign Key > auth.users.id)
  - created_at: timestamptz

user_organizations
  - id: uuid (Primary Key)
  - user_id: uuid (Foreign Key > auth.users.id)
  - organization_id: uuid (Foreign Key > organizations.id)

=== RELATIONSHIPS ===

tasks.organization_id > organizations.id
tasks.created_by > auth.users.id
user_organizations.user_id > auth.users.id
user_organizations.organization_id > organizations.id

=== MULTI-TENANCY ===

Every task row has organization_id.
All queries filter by the user's organization_id.
Users connect to organizations via user_organizations.

Your schema is designed. In the next step, you'll bring this plan to life by starting the Nhost stack and creating these tables in a real Postgres database.

Stand Up the Nhost Stack and Create Your Schema

You have a schema plan on paper. Now it is time to turn those boxes and arrows into real tables inside a running database.

In this step, you will initialize a Nhost project, start the full backend stack with a single command, and use the browser-based Dashboard to create your tables exactly as you designed them.

In this step, get ready to:
  • Initialize a Nhost project and start all backend services locally.
  • Create the organizations, user_organizations, and tasks tables in the Nhost Dashboard.
  • Configure foreign key relationships and row-level permissions.
Initialize the Nhost project and start the stack

The Nhost CLI orchestrates Postgres, Hasura, and Auth as Docker containers on your machine. One command starts everything.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir taskboard
cd taskboard
  • Initialize the Nhost project configuration by running:
nhost init

What does this command do?

The nhost init command creates a nhost/ folder inside your project. This folder contains configuration files, a migrations directory, and Hasura metadata. It does not ask any interactive questions.

  • Confirm the folder was created by running:
ls

You should see a nhost folder in the output.

  • Start all backend services by running:
nhost up

This takes a minute or two the first time because Docker needs to pull all the container images.

What should I see?

When all services are running, the terminal prints a list of URLs:

  • Postgres: postgres://postgres:postgres@localhost:5432/local
  • Hasura: https://local.hasura.local.nhost.run
  • GraphQL: https://local.graphql.local.nhost.run
  • Auth: https://local.auth.local.nhost.run
  • Dashboard: https://local.dashboard.local.nhost.run

Keep this terminal window open. You will need these services running for the rest of the project.

Services not starting?

  • Make sure Docker Desktop is running. Open it from your Dock or Applications folder and confirm the whale icon is visible in your menu bar.
  • Port 443 may be in use by another process. Stop any other local servers (like Apache or NGINX) that listen on port 443, then run nhost up again.
  • If you see DNS resolution errors for *.local.nhost.run domains, check that your network is not blocking custom domain resolution. A VPN or corporate firewall can interfere.

Still stuck? Help me troubleshoot why nhost up is failing to start services

Create the tables in the Nhost Dashboard

The Nhost Dashboard provides a browser UI for managing your database. You will create each of the three tables from your schema plan here.

  • Open your browser and navigate to https://local.dashboard.local.nhost.run/local/local.
  • Click the Database tab in the left sidebar.

You will now create three tables, starting with the simplest one.

  • Click Create Table at the top of the page.
  • In the Table Name field, enter organizations.
  • Add the following columns:
  • Column name: id. Type: uuid. Default: gen_random_uuid(). Leave nullable unchecked.
  • Column name: name. Type: text. Leave nullable unchecked.
  • Column name: created_at. Type: timestamptz. Default: now().
  • In the Primary Key dropdown, select id.
  • Click Add Table to create the table.

You should see a confirmation that the organizations table was created successfully.

Now create the join table that connects users to organizations.

  • Click Create Table again.
  • In the Table Name field, enter user_organizations.
  • Add the following columns:
  • Column name: id. Type: uuid. Default: gen_random_uuid(). Leave nullable unchecked.
  • Column name: user_id. Type: uuid. Leave nullable unchecked.
  • Column name: organization_id. Type: uuid. Leave nullable unchecked.
  • Set id as the Primary Key.
  • Click Add Table.

Finally, create the main tasks table.

  • Click Create Table one more time.
  • In the Table Name field, enter tasks.
  • Add the following columns:
  • Column name: id. Type: uuid. Default: gen_random_uuid(). Leave nullable unchecked.
  • Column name: title. Type: text. Leave nullable unchecked.
  • Column name: description. Type: text. Check nullable (descriptions are optional).
  • Column name: status. Type: text. Default: todo. Leave nullable unchecked.
  • Column name: organization_id. Type: uuid. Leave nullable unchecked.
  • Column name: created_by. Type: uuid. Leave nullable unchecked.
  • Column name: created_at. Type: timestamptz. Default: now().
  • Set id as the Primary Key.
  • Click Add Table.

Why uuid instead of integer IDs?

Using uuid primary keys means each row gets a globally unique identifier. This is standard for multi-tenant apps because it prevents ID collisions when merging data across organizations or environments.

Set up foreign keys and row-level permissions

Foreign keys enforce data integrity. They guarantee that a task's organization_id always points to an organization that actually exists in the database.

  • In the Dashboard sidebar, click on the user_organizations table.
  • Click the Modify tab.
  • Scroll down to the Foreign Keys section and click Add.
  • Set Reference Table to auth.users.
  • Set From column to user_id and To column to id.
  • Click Save.
  • Add a second foreign key on the same table.
  • Set Reference Table to organizations.
  • Set From column to organization_id and To column to id.
  • Click Save.

Now set up the foreign keys for the tasks table.

  • Click on the tasks table in the sidebar.
  • Go to the Modify tab and scroll to Foreign Keys.
  • Add a foreign key: From organization_id, To organizations.id. Click Save.
  • Add another foreign key: From created_by, To auth.users.id. Click Save.

What are these relationships doing?

Each foreign key creates a guarantee. If a task has organization_id = abc-123, the database enforces that abc-123 actually exists in the organizations table. You cannot create orphan data.

Hasura also uses these foreign keys to automatically suggest GraphQL relationships, which you will explore in the next step.

  • Go to the Relationships tab on the tasks table.
  • Hasura will suggest relationships based on the foreign keys you just created. Click Add on each suggested relationship to track them.
  • Repeat for user_organizations: go to its Relationships tab and click Add on each suggested relationship.

Now configure permissions so users can only access their own organization's data.

How do Hasura permissions work?

When a user signs in through Nhost Auth, their JWT token contains session variables like X-Hasura-User-Id. You write permission rules that compare row values against these session variables.

Hasura adds the rule as a WHERE clause to every query automatically. This is what makes the app multi-tenant without any extra code in your frontend.

  • Click on the tasks table in the sidebar.
  • Click the Permissions tab.
  • In the role input field, type user and press Enter to create a new role row.

Configure the select permission for this role first.

  • Click the select cell in the user row.
  • Under Row select permissions, choose With custom check.
  • Build the rule: organization_id _in X-Hasura-Organization-Ids. This means a user can only see tasks belonging to their organization.
  • Under Column select permissions, check Toggle All to allow reading all columns.
  • Click Save Permissions.
  • Click the insert cell in the user row.
  • Set the row check to the same rule: organization_id _in X-Hasura-Organization-Ids.
  • Under Column insert permissions, check all columns.
  • Click Save Permissions.
  • Repeat for update using the same organization_id _in X-Hasura-Organization-Ids rule. Click Save Permissions.
  • Repeat for delete using the same rule. Click Save Permissions.

What is X-Hasura-Organization-Ids?

This is a custom claim that Nhost Auth will inject into the user's JWT token. You will configure this claim in a later step when you set up authentication. For now, the permission rule is ready and waiting for it.

The _in operator checks whether organization_id exists in the array of allowed org IDs from the token. This supports users who might belong to multiple organizations in the future.

Don't see the permission builder?

  • Make sure you typed user in the role field and pressed Enter. The role row only appears after you confirm the name.
  • If the permission rule editor looks confusing, switch to the JSON editor (there is a toggle). The rule in JSON is: {"organization_id": {"_in": "X-Hasura-Organization-Ids"}}.

Need more help? Help me set up row-level permissions in Hasura

Your database is alive. Three tables with foreign keys linking them together, and permissions locking down access by organization. Next up, you will explore the GraphQL API that Hasura has already generated from these tables.

Explore the Auto-Generated GraphQL API

You have three tables with foreign keys and row-level permissions. That is everything Hasura needs to generate a complete GraphQL API for you.

In this step, you will open the Hasura Console, explore what was auto-generated, and run real queries against your database. You will also intentionally break something to see how tightly coupled the schema and API really are.

In this step, get ready to:
  • Open the Hasura Console and see the auto-generated GraphQL API.
  • Run queries and mutations in the GraphQL explorer.
  • Intentionally break the schema and observe how the API responds instantly.
Open the Hasura Console

Hasura read your table definitions and automatically created a full GraphQL API. Every table, column, and relationship you set up in the previous step now has corresponding queries, mutations, and subscriptions.

  • Open your browser and navigate to http://localhost:1337.
  • Click the API tab in the top navigation bar.

You should see the GraphQL explorer with a query editor on the left and a response panel on the right.

  • Click the Explorer sidebar (the folder icon on the left edge of the query editor).
  • Expand the available queries. You should see entries like organizations, tasks, and user_organizations.

What did Hasura auto-generate?

For every table you created, Hasura generated queries (fetch data), mutations (insert, update, delete), and subscriptions (real-time updates). It also generated filtering, sorting, and pagination arguments automatically.

You wrote zero API code. Hasura read your Postgres schema and created the entire CRUD layer for you.

Run queries and mutations in the GraphQL explorer

The explorer is not just documentation. It is a live playground where you can query your actual database. Let's insert some test data and fetch it back.

  • Clear the query editor and paste this mutation to create a test organization:
mutation {
  insert_organizations_one(object: {
    name: "Acme Corp"
  }) {
    id
    name
    created_at
  }
}
  • Click the Play button (the triangle icon) to execute the mutation.

You should see a JSON response in the right panel containing the new organization's id, name, and created_at values.

What is a mutation?

In GraphQL, a query reads data and a mutation writes data (insert, update, or delete). Hasura auto-generated both for every table.

  • Copy the id value from the response. You will use it in the next query.
  • Clear the editor and paste this mutation to create a test task, replacing the placeholder with the organization ID you just copied:
mutation {
  insert_tasks_one(object: {
    title: "Set up CI/CD pipeline"
    description: "Configure automated deploys"
    status: "todo"
    organization_id: "[[ORG_ID=\"paste your organization id here\"]]"
  }) {
    id
    title
    status
    organization_id
    created_at
  }
}
  • Click the Play button to execute the mutation.

You should see the task returned in the response with all the fields you requested.

  • Now fetch the data back. Clear the editor and paste this query:
query {
  tasks {
    id
    title
    status
    organization_id
    created_at
  }
}
  • Click the Play button.

You should see the task you just created in the response array.

  • Try adding filtering. Clear the editor and paste this query that fetches only tasks with a specific status:
query {
  tasks(where: { status: { _eq: "todo" } }) {
    id
    title
    status
  }
}
  • Click the Play button.

The response only includes tasks where status equals todo. Hasura generated these filter operators automatically from your column types.

What else does Hasura generate for free?

Beyond basic CRUD, Hasura auto-generates sorting (order_by), pagination (limit and offset), aggregations (tasks_aggregate), and real-time subscriptions. You did not write a single line of API code.

Getting a permission error?

If your query returns an empty array or a permission denied error, check the request header dropdown at the top of the explorer. Make sure x-hasura-admin-secret is set, or that you are using the admin role. As admin, you bypass row-level permissions and see all data.

help me fix a permission error in the Hasura GraphQL explorer

Break the schema and watch the API update

The connection between your database schema and the GraphQL API is live. When you change a column, the API changes instantly. This is powerful, and it also means schema mistakes immediately surface as API errors.

  • Click the Data tab in the top navigation bar.
  • Click the tasks table in the left sidebar.
  • Click the Modify tab.
  • Find the title column and click Edit next to it.
  • Rename the column from title to task_name and click Save.
  • Now go back to the API tab.
  • Run your earlier tasks query again (the one that asks for title):
query {
  tasks {
    id
    title
    status
  }
}

You should see an error. The field title no longer exists in the GraphQL schema because you renamed the column.

  • Replace title with task_name in the query and run it again:
query {
  tasks {
    id
    task_name
    status
  }
}

It works again. The API updated instantly to reflect the new column name.

Why does this matter?

This tight coupling is the key insight. In a real project, if someone renames a database column, every frontend query that references the old name breaks immediately. When something stops working, you now know to check whether the schema changed.

  • Revert the change. Go back to the Data tab.
  • Click the tasks table, then the Modify tab.
  • Click Edit next to task_name, rename it back to title, and click Save.
  • Verify the revert worked. Go to the API tab and run the original query with title again.

You should see your task data returned successfully with the title field.

Still seeing the old column name in the explorer?

The Explorer sidebar caches the schema. Click the refresh icon (circular arrows) at the top of the Explorer panel to reload the schema. If that does not work, refresh the entire browser page.

help me refresh the Hasura schema in the GraphQL explorer

Your GraphQL API is fully operational. You have seen how Hasura generates everything from your schema, and you have experienced firsthand what happens when the schema changes. Next up, you will connect a Next.js frontend to this API so your React components can fetch this data.

Set Up Next.js and Connect It to Nhost

Your database is live and your GraphQL API is auto-generated by Hasura.

The next link in the chain is a frontend that can talk to these services. In this step, you'll scaffold a Next.js app, wire it up to Nhost for authentication, and connect Apollo Client for GraphQL queries.

In this step, get ready to:
  • Scaffold a Next.js project inside your taskboard directory.
  • Install and configure the Nhost SDK and Apollo Client.
  • Verify the frontend can fetch data from your Hasura backend.
Scaffold the Next.js project and install packages

Next.js gives you a React framework with routing, server-side rendering, and a dev server out of the box. You'll scaffold it inside your taskboard/ directory so the frontend lives alongside your Nhost backend.

  • In your terminal, make sure you are inside the taskboard folder by running:
cd ~/Desktop/taskboard
  • Scaffold a new Next.js project called frontend by running:
npx create-next-app@latest frontend --typescript --no-app --src-dir

What do those flags mean?

The --typescript flag enables TypeScript so your code is type-safe from the start.

The --no-app flag uses the Pages Router instead of the App Router. Pages Router is simpler for learning the data-fetching patterns we need.

The --src-dir flag puts your source code inside a src/ folder for cleaner organization.

If the CLI asks additional questions

If prompted about ESLint, accept the default (Yes). If prompted about Tailwind CSS, select No. For any other prompt, accept the default by pressing Enter.

  • Once scaffolding finishes, move into the new frontend folder by running:
cd frontend
  • Install the Nhost SDK, Nhost React bindings, Apollo Client, and the GraphQL package by running:
npm install @nhost/nhost-js @nhost/react @apollo/client graphql

Why four packages?

  • @nhost/nhost-js is the core Nhost SDK that handles authentication (sign up, sign in, tokens).
  • @nhost/react provides React context and hooks so your components can access the Nhost client.
  • @apollo/client handles GraphQL queries with caching, loading states, and type-safe hooks.
  • graphql is the base library that Apollo Client depends on for parsing queries.
  • Confirm the packages installed by running:
ls node_modules/@nhost node_modules/@apollo

You should see folders like nhost-js, react, and client listed in the output.

Install failed or packages missing?

  • Make sure you are inside the frontend/ folder (not the parent taskboard/ folder) before running npm install.
  • If you see permission errors, try running sudo npm install and entering your Mac password.

Help me debug my npm install error

Configure the Nhost and Apollo clients

Your frontend needs two clients. The Nhost client handles authentication (who is the user). The Apollo client handles data fetching (what data can this user see).

  • Create a new folder called lib inside src/ by running:
mkdir -p src/lib
  • Create the Nhost client file by running:
touch src/lib/nhost.ts
  • Open src/lib/nhost.ts in VS Code and paste the following code:
import { NhostClient } from '@nhost/nhost-js';

// Create the Nhost client pointing at your local backend
export const nhost = new NhostClient({ subdomain: 'local', region: 'local' });
  • Now create the Apollo client file by running:
touch src/lib/apollo.ts
  • Open src/lib/apollo.ts in VS Code and paste the following code:
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { nhost } from './nhost';

// Point Apollo at the local Hasura GraphQL endpoint
const httpLink = createHttpLink({ uri: 'http://localhost:1337/v1/graphql' });

// Attach the user's auth token to every GraphQL request
const authLink = setContext(async (_, { headers }) => {
  const token = await nhost.auth.getAccessToken();
  return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '' } };
});

// Combine the auth link and http link into one Apollo Client
export const apolloClient = new ApolloClient({ link: authLink.concat(httpLink), cache: new InMemoryCache() });

What does this code do?

The Nhost client in nhost.ts connects to your local Nhost Auth service. It manages sign-up, sign-in, and access tokens.

The Apollo client in apollo.ts does two things. First, httpLink points to your Hasura GraphQL endpoint at http://localhost:1337/v1/graphql. Second, authLink grabs the JWT from Nhost and attaches it as a Bearer token so Hasura knows which user is making the request.

This is the token flow: user signs in through Nhost, Nhost returns a JWT, Apollo attaches that JWT to every GraphQL request, and Hasura reads the JWT to enforce your row-level permissions.

  • Save both src/lib/nhost.ts and src/lib/apollo.ts.

Now you need to wrap your entire app with both providers so every page can access authentication and GraphQL.

  • Open src/pages/_app.tsx in VS Code.
  • Replace the entire file contents with the following code:
import { NhostProvider } from '@nhost/react';
import { ApolloProvider } from '@apollo/client';
import { nhost } from '../lib/nhost';
import { apolloClient } from '../lib/apollo';

// Wrap the app with auth and data providers
export default function App({ Component, pageProps }) {
  return (
    <NhostProvider nhost={nhost}>
      <ApolloProvider client={apolloClient}>
        <Component {...pageProps} />
      </ApolloProvider>
    </NhostProvider>
  );
}

What are providers?

In React, providers make a value (like a client) available to every component in your app without passing it through props manually. NhostProvider gives every component access to authentication. ApolloProvider gives every component access to GraphQL queries.

By nesting them in _app.tsx, every page in your app automatically has both capabilities.

  • Save src/pages/_app.tsx.

Seeing red squiggly lines in VS Code?

  • If VS Code shows an error on the import lines, make sure you ran npm install inside the frontend/ folder.
  • If the error is on '../lib/nhost' or '../lib/apollo', check that your files are in src/lib/ (not lib/ at the project root).

Help me fix import errors in my _app.tsx file

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

import { NhostClient } from '@nhost/nhost-js';

export const nhost = new NhostClient({ subdomain: 'local', region: 'local' });
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { nhost } from './nhost';

const httpLink = createHttpLink({ uri: 'http://localhost:1337/v1/graphql' });

const authLink = setContext(async (_, { headers }) => {
  const token = await nhost.auth.getAccessToken();
  return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '' } };
});

export const apolloClient = new ApolloClient({ link: authLink.concat(httpLink), cache: new InMemoryCache() });
import { NhostProvider } from '@nhost/react';
import { ApolloProvider } from '@apollo/client';
import { nhost } from '../lib/nhost';
import { apolloClient } from '../lib/apollo';

export default function App({ Component, pageProps }) {
  return (
    <NhostProvider nhost={nhost}>
      <ApolloProvider client={apolloClient}>
        <Component {...pageProps} />
      </ApolloProvider>
    </NhostProvider>
  );
}
Verify the frontend connects to your backend

Configuration is done. Now you need to confirm the chain actually works: Next.js starts, Apollo connects to Hasura, and you can fetch data.

  • Make sure your Nhost stack is still running. If you stopped it, open a new terminal tab, navigate to ~/Desktop/taskboard, and run nhost up.
  • Back in your frontend/ terminal, start the Next.js dev server by running:
npm run dev
  • Open your browser and go to http://localhost:3000.

You should see the default Next.js welcome page. This confirms the app is running.

  • Now test the GraphQL connection. Open src/pages/index.tsx and replace the entire file with this test code:
import { gql, useQuery } from '@apollo/client';

// A simple query to test the connection to Hasura
const TEST_QUERY = gql`
  query TestConnection {
    organizations {
      id
      name
    }
  }
`;

export default function Home() {
  const { data, loading, error } = useQuery(TEST_QUERY);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Connection works, but got: {error.message}</p>;

  return (
    <div>
      <h1>Connection successful!</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

What should I see?

Since you are not logged in, Hasura will apply the default (unauthenticated) role. You will likely see either "Connection successful!" with an empty organizations list, or a message like "Connection works, but got: ..." with a permissions error.

Both results confirm the connection is working. An empty list means Hasura responded (just no public data). A permissions error means Hasura received the request and actively denied it based on your row-level rules. Either way, your frontend is talking to Hasura.

  • Save src/pages/index.tsx and refresh your browser at http://localhost:3000.
  • Confirm you see either "Connection successful!" or "Connection works, but got:" followed by an error message.

Seeing a blank page or a network error?

  • Check that your Nhost stack is running. Open http://localhost:1337 in your browser. If it does not load, run nhost up from the taskboard/ directory.
  • Verify the GraphQL URL in apollo.ts is exactly http://localhost:1337/v1/graphql (not https, and not port 3030).
  • Open your browser's DevTools (right-click, Inspect, Network tab) and look for a failed request to see the exact error.

Help me debug my Next.js to Hasura connection

Your frontend is connected to your backend. Next up, you'll set up GraphQL Codegen to automatically generate type-safe hooks from your schema. No more writing queries by hand.

Set Up GraphQL Codegen

Your backend is running and your frontend can talk to it. But right now, if you wanted to fetch tasks in a React component, you would need to manually write GraphQL query strings and define TypeScript types for the response data. That is tedious and error-prone.

GraphQL Codegen solves this by reading your Hasura schema and automatically generating typed React hooks. You write a .graphql file describing what data you want, and codegen produces a ready-to-use hook like useGetTasksQuery() with full type safety.

In this step, get ready to:
  • Install GraphQL Codegen and configure it to read your Hasura schema.
  • Write your first GraphQL operations and run codegen to generate typed hooks.
  • Explore the generated file to understand how your database tables became TypeScript types.
Install GraphQL Codegen packages

GraphQL Codegen is a tool that reads a GraphQL schema (in this case, the one Hasura auto-generated from your tables) and outputs TypeScript code. You need four packages: the CLI runner, the base TypeScript type generator, the operations type generator, and the React Apollo hook generator.

  • In your terminal, make sure you are inside the taskboard/frontend directory.
  • Install the codegen packages as dev dependencies by running:
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo

What are these four packages?

  • @graphql-codegen/cli is the command-line runner that orchestrates the generation process.
  • @graphql-codegen/typescript generates base TypeScript types matching your schema (like a Tasks type with all its fields).
  • @graphql-codegen/typescript-operations generates types for your specific queries and mutations (like GetTasksQuery and CreateTaskMutation).
  • @graphql-codegen/typescript-react-apollo generates ready-to-use React hooks like useGetTasksQuery() that you can drop straight into components.
  • You should see the packages added to devDependencies in your package.json.

Install failed or timed out?

  • Make sure you are inside taskboard/frontend (where your package.json lives), not the parent taskboard directory.
  • If you see permission errors, try running npm cache clean --force and then retry the install.

Help me debug my codegen package installation

  • Add a codegen script to your package.json by running:
npm pkg set scripts.codegen="graphql-codegen --config codegen.yml"

This adds a codegen script to your package.json so you can run generation with npm run codegen.

Configure codegen to read your Hasura schema

Codegen needs to know three things: where your GraphQL schema lives, where your operation files are, and where to output the generated code. You define all of this in a YAML config file.

  • Create a file called codegen.yml in the taskboard/frontend folder (the same level as package.json).
  • Paste the following configuration into codegen.yml:
overwrite: true
schema:
  - http://localhost:1337/v1/graphql:
      headers:
        x-hasura-admin-secret: nhost-admin-secret
documents: 'src/graphql/**/*.graphql'
generates:
  src/generated/graphql.tsx:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo

What does this config do?

  • schema tells codegen where to fetch the GraphQL schema from. This is your local Hasura endpoint. The admin secret header gives codegen full access to read the schema.
  • documents tells codegen where to find your .graphql files containing queries and mutations.
  • generates defines the output file and which plugins to run. The three plugins produce base types, operation types, and React Apollo hooks respectively.
  • Save codegen.yml.
Write your first GraphQL operations and run codegen

Before codegen can generate hooks, it needs .graphql files that describe which data you want to fetch or modify. These are the same queries you tested in the Hasura Console, but now saved as files your frontend tooling can process.

  • Create the GraphQL operations directory by running:
mkdir -p src/graphql
  • Create a file called tasks.graphql inside src/graphql/.
  • Paste the following operations into tasks.graphql:
# Fetch all tasks for a specific organization
query GetTasks($organization_id: uuid!) {
  tasks(where: { organization_id: { _eq: $organization_id } }) {
    id
    title
    description
    status
    created_at
  }
}

# Create a new task in an organization
mutation CreateTask($title: String!, $description: String, $organization_id: uuid!, $created_by: uuid!) {
  insert_tasks_one(object: { title: $title, description: $description, organization_id: $organization_id, created_by: $created_by }) {
    id
    title
    description
    status
    created_at
  }
}

What do these operations describe?

The GetTasks query fetches all tasks for a given organization, filtered by organization_id. This is multi-tenancy in action: you only ever request your own org's data.

The CreateTask mutation inserts a new task and returns the created row. Hasura generated insert_tasks_one automatically from your tasks table.

  • Save tasks.graphql.
  • Make sure your Nhost stack is still running (if you stopped it, run nhost up again from the taskboard directory).
  • Run codegen by executing:
npm run codegen
  • You should see output indicating that src/generated/graphql.tsx was generated successfully.

Codegen failed to fetch the schema?

  • Confirm your Nhost stack is running. Open http://localhost:1337/v1/graphql in your browser. You should see the Hasura GraphQL endpoint.
  • Check that the x-hasura-admin-secret value in codegen.yml matches your local setup. The default for Nhost local development is nhost-admin-secret.
  • Make sure you are running npm run codegen from inside taskboard/frontend (where codegen.yml lives).

Help me debug why GraphQL Codegen cannot connect to my Hasura schema

  • Open the generated file at src/generated/graphql.tsx in VS Code.

Scroll through the file. You will see TypeScript interfaces for your tables, types for your query variables and responses, and complete React hooks ready to import.

What did codegen just do?

Codegen reached out to your running Hasura instance, downloaded the full GraphQL schema (which Hasura built from your database tables), then combined that schema with the operations you wrote in tasks.graphql.

The result is a single TypeScript file with everything typed. When you call useGetTasksQuery() in a component, TypeScript already knows the response shape. If you rename a column in Hasura and re-run codegen, TypeScript will immediately flag every component that referenced the old name.

Why generate hooks instead of writing them manually?

You could write useQuery(GET_TASKS) by hand and define types yourself. But with 10 tables and 30 operations, that becomes hundreds of lines of boilerplate that must stay in sync with your schema. Codegen guarantees your frontend types always match your database.

✔️ Awesome, I've got everything!

Double check that you have saved both codegen.yml and tasks.graphql, and that src/generated/graphql.tsx exists with content.

ⓧ I'd like to double check the full code

Here is the full content of both files you created in this step:

overwrite: true
schema:
  - http://localhost:1337/v1/graphql:
      headers:
        x-hasura-admin-secret: nhost-admin-secret
documents: 'src/graphql/**/*.graphql'
generates:
  src/generated/graphql.tsx:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
query GetTasks($organization_id: uuid!) {
  tasks(where: { organization_id: { _eq: $organization_id } }) {
    id
    title
    description
    status
    created_at
  }
}

mutation CreateTask($title: String!, $description: String, $organization_id: uuid!, $created_by: uuid!) {
  insert_tasks_one(object: { title: $title, description: $description, organization_id: $organization_id, created_by: $created_by }) {
    id
    title
    description
    status
    created_at
  }
}

You now have the complete pipeline running: your database tables generate a GraphQL API via Hasura, and codegen turns that API into typed React hooks. Next up, you will use these generated hooks to build the actual frontend: a login page, a task list, and a task creation form.

Build the Initial Frontend

Your backend is fully wired: the database holds your schema, Hasura exposes it as a GraphQL API, and codegen turned that API into typed React hooks. Now it is time to build the screens that put those hooks to work.

In this step you will create the user-facing pieces of your task board. A login page handles authentication, a TaskList component displays tasks, a TaskForm component creates them, and a dashboard ties everything together.

In this step, get ready to:
  • Create a login page that signs users up and in with email and password.
  • Build TaskList and TaskForm components using generated GraphQL hooks.
  • Wire components into a protected dashboard and verify the full flow.
Create the login page

Every multi-tenant app starts with authentication. The login page lets users create an account or sign in, then redirects them to the dashboard.

  • Create a new file at src/pages/index.tsx inside your frontend folder.
  • Paste the following code into it:
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuthenticationStatus } from '@nhost/react';
import { nhost } from '../lib/nhost';

export default function LoginPage() {
  // Form state
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSignUp, setIsSignUp] = useState(false);
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const router = useRouter();
  const { isAuthenticated, isLoading } = useAuthenticationStatus();

  // Redirect authenticated users straight to the dashboard
  if (isAuthenticated) {
    router.push('/dashboard');
    return null;
  }

  if (isLoading) return <p>Loading...</p>;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      if (isSignUp) {
        // Create a new account
        const { error: signUpError } = await nhost.auth.signUpEmailPassword({
          email,
          password,
        });
        if (signUpError) setError(signUpError.message);
      } else {
        // Sign in an existing user
        const { error: signInError } = await nhost.auth.signInEmailPassword({
          email,
          password,
        });
        if (signInError) setError(signInError.message);
      }
    } catch (err) {
      setError('Something went wrong. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
      <h1>{isSignUp ? 'Sign Up' : 'Sign In'}</h1>
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
        />
        {error && <p style={{ color: 'red' }}>{error}</p>}
        <button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
          {loading ? 'Please wait...' : isSignUp ? 'Sign Up' : 'Sign In'}
        </button>
      </form>
      <p style={{ marginTop: 16 }}>
        {isSignUp ? 'Already have an account?' : 'Need an account?'}{' '}
        <button
          onClick={() => setIsSignUp(!isSignUp)}
          style={{ background: 'none', border: 'none', color: 'blue', cursor: 'pointer' }}
        >
          {isSignUp ? 'Sign In' : 'Sign Up'}
        </button>
      </p>
    </div>
  );
}

What does this code do?

useAuthenticationStatus() is a React hook from @nhost/react that returns the current auth state. If the user is already signed in, the page immediately redirects to /dashboard.

The handleSubmit function calls either signUpEmailPassword or signInEmailPassword on the nhost client you configured in the previous step. On success, the NhostProvider detects the new session and isAuthenticated flips to true, triggering the redirect.

  • Save src/pages/index.tsx.

Seeing TypeScript errors on the JSX?

Make sure your tsconfig.json has "jsx": "preserve" (the Next.js default). If you scaffolded with create-next-app this is already set.

help me fix TypeScript errors in my login page

Build the TaskList and TaskForm components

The generated hooks from codegen do the heavy lifting. You just need thin React components that call them and render the results.

  • Create a components folder inside src/ if it does not already exist.
  • Create a new file at src/components/TaskList.tsx and paste the following code:
import { useGetTasksQuery } from '../generated/graphql';

interface TaskListProps {
  organizationId: string;
}

export default function TaskList({ organizationId }: TaskListProps) {
  // Fetch tasks scoped to the user's organization
  const { data, loading, error } = useGetTasksQuery({
    variables: { organization_id: organizationId },
  });

  if (loading) return <p>Loading tasks...</p>;
  if (error) return <p>Error loading tasks: {error.message}</p>;

  const tasks = data?.tasks || [];

  return (
    <div>
      <h2>Tasks</h2>
      {tasks.length === 0 ? (
        <p>No tasks yet. Create one below!</p>
      ) : (
        <ul>
          {tasks.map((task) => (
            <li key={task.id} style={{ marginBottom: 8 }}>
              <strong>{task.title}</strong>
              <span style={{ marginLeft: 8, color: '#666' }}>({task.status})</span>
              {task.description && <p style={{ margin: '4px 0 0 0', fontSize: 14 }}>{task.description}</p>}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

What does this code do?

useGetTasksQuery is the typed hook that codegen generated from your GetTasks query in tasks.graphql. It accepts organization_id as a variable and returns only the tasks belonging to that org.

The component handles three states: loading, error, and data. This is a pattern you will use for every query component.

  • Save src/components/TaskList.tsx.
  • Now create src/components/TaskForm.tsx and paste the following code:
import { useState } from 'react';
import { useCreateTaskMutation } from '../generated/graphql';

interface TaskFormProps {
  organizationId: string;
  userId: string;
}

export default function TaskForm({ organizationId, userId }: TaskFormProps) {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');

  // Mutation hook for creating a task, with automatic refetch of the task list
  const [createTask, { loading }] = useCreateTaskMutation({
    refetchQueries: ['GetTasks'],
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim()) return;

    await createTask({
      variables: {
        title,
        description: description || null,
        organization_id: organizationId,
        created_by: userId,
      },
    });

    // Clear the form after successful creation
    setTitle('');
    setDescription('');
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: 24 }}>
      <h2>Create Task</h2>
      <input
        type="text"
        placeholder="Task title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        required
        style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
      />
      <textarea
        placeholder="Description (optional)"
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
      />
      <button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
        {loading ? 'Creating...' : 'Create Task'}
      </button>
    </form>
  );
}

What does this code do?

useCreateTaskMutation is the typed mutation hook codegen generated from your CreateTask mutation. The refetchQueries: ['GetTasks'] option tells Apollo to automatically re-run the task list query after creating a new task.

This means the new task appears in the list immediately without a page refresh. Apollo's cache management handles the update for you.

  • Save src/components/TaskForm.tsx.

Import errors on the generated hooks?

If VS Code underlines useGetTasksQuery or useCreateTaskMutation in red, make sure you ran codegen in the previous step and that src/generated/graphql.tsx exists.

help me fix import errors for my generated GraphQL hooks

Create the dashboard and test the full flow

The dashboard is the protected page that brings TaskList and TaskForm together. It also needs the user's organization ID to scope all queries correctly.

  • Create a new file at src/pages/dashboard.tsx and paste the following code:
import { useRouter } from 'next/router';
import { useAuthenticationStatus, useUserData } from '@nhost/react';
import { useQuery, gql } from '@apollo/client';
import TaskList from '../components/TaskList';
import TaskForm from '../components/TaskForm';

// Inline query to fetch the current user's organization
const GET_USER_ORG = gql`
  query GetUserOrganization($user_id: uuid!) {
    user_organizations(where: { user_id: { _eq: $user_id } }, limit: 1) {
      organization_id
    }
  }
`;

export default function Dashboard() {
  const router = useRouter();
  const { isAuthenticated, isLoading } = useAuthenticationStatus();
  const user = useUserData();

  // Redirect unauthenticated users to the login page
  if (!isLoading && !isAuthenticated) {
    router.push('/');
    return null;
  }

  // Fetch the user's organization once authenticated
  const { data: orgData, loading: orgLoading } = useQuery(GET_USER_ORG, {
    variables: { user_id: user?.id },
    skip: !user?.id,
  });

  if (isLoading || !user || orgLoading) return <p>Loading...</p>;

  const organizationId = orgData?.user_organizations?.[0]?.organization_id;

  if (!organizationId) {
    return (
      <div style={{ maxWidth: 600, margin: '40px auto', padding: 20 }}>
        <h1>Task Board</h1>
        <p>You are not assigned to an organization yet.</p>
        <p>Ask an admin to add you, or create test data in the Nhost Dashboard.</p>
      </div>
    );
  }

  return (
    <div style={{ maxWidth: 600, margin: '40px auto', padding: 20 }}>
      <h1>Task Board</h1>
      <p>Signed in as {user.email}</p>
      <TaskForm organizationId={organizationId} userId={user.id} />
      <hr style={{ margin: '20px 0' }} />
      <TaskList organizationId={organizationId} />
    </div>
  );
}

What does this code do?

The dashboard is a protected page. If isAuthenticated is false after loading, it redirects to the login page.

useUserData() gives us the signed-in user's id and email. We then run an inline GraphQL query against the user_organizations table to find which org this user belongs to. That org ID flows into both TaskList and TaskForm as a prop, scoping all data.

  • Save src/pages/dashboard.tsx.

Before you can test the full flow, you need a test organization and a link between your user and that org.

  • Open the Nhost Dashboard at http://localhost:3030.
  • Navigate to the Hasura console.
  • In the organizations table, click Insert Row and add an organization with a name like Test Org. Copy the generated id value.

Now start the frontend dev server and create a user account.

  • Open your terminal inside the taskboard/frontend folder and start the dev server by running:
npm run dev
  • Open http://localhost:3000 in your browser.
  • Click Sign Up, enter an email and password, and submit.

After signing up, you will land on the dashboard. It should say "You are not assigned to an organization yet" because the user_organizations link does not exist.

  • Go back to the Hasura console.
  • In the user_organizations table, insert a row linking the user you just created (copy the user ID from the auth.users table) to the organization you created.
  • Refresh http://localhost:3000/dashboard in your browser.

You should now see the Task Board with the Create Task form and an empty task list.

  • Type a title like My first task and click Create Task.
  • The task should appear in the list immediately below, showing the title and status todo.

Task not appearing after creation?

Check the browser console for GraphQL errors. A common cause is row-level permissions blocking the insert. In the Hasura console, verify that the tasks table's insert permission for the user role allows inserts where organization_id matches x-hasura-organization-id.

help me debug why my task creation mutation is failing

How does the full chain work here?

Follow the data path: you type a title and click Create Task. React calls useCreateTaskMutation, which sends a GraphQL mutation to Hasura via Apollo Client. Apollo attaches your JWT (from Nhost Auth) as a Bearer token.

Hasura reads the JWT claims, checks row-level permissions, and inserts the row into Postgres. Apollo then refetches GetTasks, Hasura filters by your org's ID, and the new task appears on screen. Database to screen, through six layers, in under a second.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files.

ⓧ I'd like to double check the full code

Here are all the files you created or modified in this step.

import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuthenticationStatus } from '@nhost/react';
import { nhost } from '../lib/nhost';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSignUp, setIsSignUp] = useState(false);
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const router = useRouter();
  const { isAuthenticated, isLoading } = useAuthenticationStatus();

  if (isAuthenticated) {
    router.push('/dashboard');
    return null;
  }

  if (isLoading) return <p>Loading...</p>;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      if (isSignUp) {
        const { error: signUpError } = await nhost.auth.signUpEmailPassword({
          email,
          password,
        });
        if (signUpError) setError(signUpError.message);
      } else {
        const { error: signInError } = await nhost.auth.signInEmailPassword({
          email,
          password,
        });
        if (signInError) setError(signInError.message);
      }
    } catch (err) {
      setError('Something went wrong. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
      <h1>{isSignUp ? 'Sign Up' : 'Sign In'}</h1>
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
        />
        {error && <p style={{ color: 'red' }}>{error}</p>}
        <button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
          {loading ? 'Please wait...' : isSignUp ? 'Sign Up' : 'Sign In'}
        </button>
      </form>
      <p style={{ marginTop: 16 }}>
        {isSignUp ? 'Already have an account?' : 'Need an account?'}{' '}
        <button
          onClick={() => setIsSignUp(!isSignUp)}
          style={{ background: 'none', border: 'none', color: 'blue', cursor: 'pointer' }}
        >
          {isSignUp ? 'Sign In' : 'Sign Up'}
        </button>
      </p>
    </div>
  );
}
import { useGetTasksQuery } from '../generated/graphql';

interface TaskListProps {
  organizationId: string;
}

export default function TaskList({ organizationId }: TaskListProps) {
  const { data, loading, error } = useGetTasksQuery({
    variables: { organization_id: organizationId },
  });

  if (loading) return <p>Loading tasks...</p>;
  if (error) return <p>Error loading tasks: {error.message}</p>;

  const tasks = data?.tasks || [];

  return (
    <div>
      <h2>Tasks</h2>
      {tasks.length === 0 ? (
        <p>No tasks yet. Create one below!</p>
      ) : (
        <ul>
          {tasks.map((task) => (
            <li key={task.id} style={{ marginBottom: 8 }}>
              <strong>{task.title}</strong>
              <span style={{ marginLeft: 8, color: '#666' }}>({task.status})</span>
              {task.description && <p style={{ margin: '4px 0 0 0', fontSize: 14 }}>{task.description}</p>}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
import { useState } from 'react';
import { useCreateTaskMutation } from '../generated/graphql';

interface TaskFormProps {
  organizationId: string;
  userId: string;
}

export default function TaskForm({ organizationId, userId }: TaskFormProps) {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');

  const [createTask, { loading }] = useCreateTaskMutation({
    refetchQueries: ['GetTasks'],
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim()) return;

    await createTask({
      variables: {
        title,
        description: description || null,
        organization_id: organizationId,
        created_by: userId,
      },
    });

    setTitle('');
    setDescription('');
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: 24 }}>
      <h2>Create Task</h2>
      <input
        type="text"
        placeholder="Task title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        required
        style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
      />
      <textarea
        placeholder="Description (optional)"
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        style={{ display: 'block', width: '100%', marginBottom: 10, padding: 8 }}
      />
      <button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
        {loading ? 'Creating...' : 'Create Task'}
      </button>
    </form>
  );
}
import { useRouter } from 'next/router';
import { useAuthenticationStatus, useUserData } from '@nhost/react';
import { useQuery, gql } from '@apollo/client';
import TaskList from '../components/TaskList';
import TaskForm from '../components/TaskForm';

const GET_USER_ORG = gql`
  query GetUserOrganization($user_id: uuid!) {
    user_organizations(where: { user_id: { _eq: $user_id } }, limit: 1) {
      organization_id
    }
  }
`;

export default function Dashboard() {
  const router = useRouter();
  const { isAuthenticated, isLoading } = useAuthenticationStatus();
  const user = useUserData();

  if (!isLoading && !isAuthenticated) {
    router.push('/');
    return null;
  }

  const { data: orgData, loading: orgLoading } = useQuery(GET_USER_ORG, {
    variables: { user_id: user?.id },
    skip: !user?.id,
  });

  if (isLoading || !user || orgLoading) return <p>Loading...</p>;

  const organizationId = orgData?.user_organizations?.[0]?.organization_id;

  if (!organizationId) {
    return (
      <div style={{ maxWidth: 600, margin: '40px auto', padding: 20 }}>
        <h1>Task Board</h1>
        <p>You are not assigned to an organization yet.</p>
        <p>Ask an admin to add you, or create test data in the Nhost Dashboard.</p>
      </div>
    );
  }

  return (
    <div style={{ maxWidth: 600, margin: '40px auto', padding: 20 }}>
      <h1>Task Board</h1>
      <p>Signed in as {user.email}</p>
      <TaskForm organizationId={organizationId} userId={user.id} />
      <hr style={{ margin: '20px 0' }} />
      <TaskList organizationId={organizationId} />
    </div>
  );
}

Your frontend is alive. You have a working login, a task list driven by typed hooks, and a creation form that round-trips data through every layer of the stack. Next up, you will intentionally break things to learn how to debug each layer when something goes wrong.

Break Things and Learn to Debug the Chain

Your task board works end-to-end. You can log in, create tasks, and see them appear. But what happens when something breaks?

In professional full-stack projects, errors can come from any layer in the chain. The database might change, codegen might fail, or authentication might stop working. In this step, you will intentionally break three different layers and learn to trace errors back to their source.

In this step, get ready to:
  • Break the database layer by renaming a column and observe the frontend error.
  • Break the codegen layer by pointing it at a wrong URL and observe stale types.
  • Break the Apollo layer by removing auth headers and observe the permission error.
Break the database layer

The database sits at the bottom of the chain. When a column name changes in Postgres, Hasura's GraphQL API updates instantly. Your frontend code still references the old column name, so it breaks.

  • Open the Nhost Dashboard at http://localhost:3030.
  • Navigate to the Hasura tab and open the Hasura Console.
  • Click the Data tab in the top navigation.
  • Select the tasks table from the sidebar.
  • Click Modify to view the table structure.
  • Rename the title column to task_title and click Save.

Now switch to your browser and open the dashboard page at http://localhost:3000/dashboard. Log in if prompted.

  • Open your browser's DevTools by pressing Cmd+Option+I (macOS).
  • Click the Network tab.
  • Look for the GraphQL request to /v1/graphql and click on it.
  • In the response, you should see an error mentioning that the field title does not exist on the type tasks.

What just happened?

Your tasks.graphql file still asks for a field called title. But the database column is now called task_title. Hasura instantly updated its GraphQL schema to reflect the new column name, so the old query is invalid.

This is the debugging pattern: the error message tells you the field does not exist. That points to the database layer as the source of the problem.

  • Go back to the Hasura Console.
  • Rename the column back from task_title to title and click Save.
  • Refresh your dashboard page. Your task list should work again.

Tasks still not loading after reverting?

Make sure you saved the column rename in the Hasura Console. The rename takes effect immediately, so a page refresh in your browser should fix the issue.

If you still see errors, try a hard refresh with Cmd+Shift+R to clear the browser cache. Help me debug why my tasks query still fails after reverting the column name.

Break the codegen layer

Codegen reads your Hasura schema to generate TypeScript types and hooks. If it cannot reach Hasura, it fails silently and your types become stale.

  • In VS Code, open codegen.yml in the taskboard/frontend folder.
  • Find this line in the schema section:
- http://localhost:1337/v1/graphql:
  • Change the port from 1337 to 9999 (a port nothing is running on), so it reads:
- http://localhost:9999/v1/graphql:
  • Save codegen.yml.
  • Run codegen in your terminal:
npx graphql-codegen

You should see an error like connect ECONNREFUSED 127.0.0.1:9999 or a similar network failure message.

Why is this dangerous?

When codegen fails, your generated file (src/generated/graphql.tsx) stays unchanged. It still has the old types from the last successful run. Your code compiles fine, but the types might not match what the database actually looks like.

This is the debugging pattern: if your types feel "out of date" or your frontend works in unexpected ways, check whether codegen ran successfully. A silent failure here causes confusing bugs downstream.

  • Fix the URL by changing the port back to 1337 in codegen.yml:
- http://localhost:1337/v1/graphql:
  • Save the file and re-run codegen to confirm it works:
npx graphql-codegen

You should see output confirming the file was generated successfully with no errors.

Codegen still failing after fixing the URL?

Make sure your Nhost stack is still running. If you stopped it earlier, run nhost up again from the taskboard directory. Codegen needs Hasura to be reachable at port 1337. Help me fix my codegen configuration.

Break the authentication layer

Hasura uses the authorization header to identify who is making a request. Without it, Hasura applies "public" permissions, which in your setup means no data access at all.

  • In VS Code, open src/lib/apollo.ts in the taskboard/frontend folder.
  • Find the authLink section that adds the authorization header:
const authLink = setContext(async (_, { headers }) => {
  const token = await nhost.auth.getAccessToken();
  return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '' } };
});
  • Replace the authorization line with an empty string so no token is sent:
const authLink = setContext(async (_, { headers }) => {
  return { headers: { ...headers, authorization: '' } };
});
  • Save apollo.ts.
  • Open your browser and navigate to http://localhost:3000/dashboard.
  • Open DevTools with Cmd+Option+I and click the Network tab.
  • Find the GraphQL request and look at the response body.

You should see an empty data response or a permission error from Hasura. Your task list will appear empty, even though the tasks still exist in the database.

What just happened?

Without the Bearer token, Hasura does not know who is making the request. Your row-level permissions say "only show tasks where the user belongs to the same organization." With no user identity, Hasura returns nothing.

This is the debugging pattern: if data disappears but there is no error message in the console, check the Network tab. A 200 response with empty data usually means a permissions issue at the Hasura layer.

  • Revert your change in apollo.ts so the authLink reads the token from Nhost again:
const authLink = setContext(async (_, { headers }) => {
  const token = await nhost.auth.getAccessToken();
  return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '' } };
});
  • Save apollo.ts and refresh your dashboard.
  • Your tasks should reappear in the list.

Tasks still not appearing after restoring the auth header?

You may need to sign out and sign back in to get a fresh token. Check that your authLink code exactly matches the original, including the await keyword before nhost.auth.getAccessToken(). Help me fix my Apollo auth setup.

Your debugging mental model

You now have a pattern for tracing errors in any full-stack project like this:

  • "Field does not exist" errors point to the database/schema layer. Something changed in the database that your queries do not match.
  • TypeScript errors or stale types point to the codegen layer. Re-run codegen and check that it completes successfully.
  • Empty data with a 200 response points to the auth/permissions layer. Check that the token is being sent and that Hasura permissions are correct.

You now know where to look when something goes wrong. Next up, a new requirement arrives: tasks need comments. You will plan the schema change and see how the full pipeline repeats.

Plan the Comments Feature

You now have a mental model for tracing errors across the stack. You know which layer to investigate when something breaks.

Here comes the real test of that mental model: a new requirement has arrived. Tasks need comments. This is how professional projects actually grow. A stakeholder says "we need comments on tasks" and your job is to figure out what changes across every layer of your stack before you touch a single tool.

In this step, get ready to:
  • Design a comments table with columns, foreign keys, and permission rules.
  • Identify which existing components need changes and which new components to build.
  • Plan the GraphQL operations the frontend will need.
Design the comments table

Every schema change starts with the same question: what is this thing, and what does it relate to? A comment belongs to a specific task, and a specific user wrote it.

Grab a piece of paper or open a blank document. Write down the following plan for your new comments table:

  • Write the table name: comments.
  • Write these columns: id (uuid, primary key, default gen_random_uuid()), body (text, not null), task_id (uuid, foreign key to tasks.id), author_id (uuid, foreign key to auth.users.id), and created_at (timestamptz, default now()).
  • Write the two foreign key relationships: task_id points to tasks.id and author_id points to auth.users.id.

Why plan on paper first?

When you plan on paper, you catch design mistakes before they cascade through the stack. A renamed column on paper costs nothing. A renamed column after you have built components, written queries, and generated types costs you debugging time in every layer.

Plan the permissions

Permissions are the multi-tenancy boundary. You need to decide who can see and create comments before you build anything.

  • Write down the permission rule for select: users can read comments on tasks that belong to their organization.
  • Write down the permission rule for insert: users can create comments only on tasks that belong to their organization.
  • Note how the permission check crosses a relationship: the comment's task_id leads to a task, and that task has an organization_id that must match the user's org.

How does Hasura check a relationship-based permission?

In Hasura, you can write a permission that follows a foreign key. For comments, you would say: allow select where the comment's task's organization_id equals the user's x-hasura-organization-id claim.

Hasura resolves this by joining across the foreign key at query time. You define it once in the permission config, and every query is automatically filtered.

Map the frontend impact

Adding a feature to a running app always falls into three categories: components you modify, components you create, and code you can remove. Planning this now prevents surprises later.

  • Write down the components that need modification: TaskList (or a TaskCard sub-component) needs to show a comment count next to each task.
  • Write down the new components: CommentList (displays comments for a task), CommentForm (form to add a comment), and TaskDetail (a new view that composes TaskCard + CommentList + CommentForm).
  • Write down the new GraphQL operations you will need: GetComments (query, takes a task_id) and AddComment (mutation, takes body, task_id, and author_id).

Why think about this before touching code?

Mapping the impact tells you how big the change is. A new table with new components is additive. Your existing code keeps working. You add to it.

This is different from modifying an existing table (which you will do in a later step). When you modify existing tables, existing code can break. Knowing the difference helps you estimate effort and risk.

Take a moment to review your plan. You should have written down the table design, the permission rules, the component map, and the GraphQL operations.

✔️ Awesome, I've got my plan!

Great. Double check your plan has all four sections: table design, permissions, component impact, and GraphQL operations.

ⓧ I'd like to double check the full plan

Here is the complete plan you should have written down:

Table: comments

  • id (uuid, PK, default gen_random_uuid()).
  • body (text, not null).
  • task_id (uuid, FK to tasks.id).
  • author_id (uuid, FK to auth.users.id).
  • created_at (timestamptz, default now()).

Permissions:

  • Select: users can read comments where the comment's task's organization_id matches their org.
  • Insert: users can create comments only on tasks belonging to their organization.

Frontend impact:

  • Modified: TaskList/TaskCard to show comment count.
  • New: CommentList, CommentForm, TaskDetail.

GraphQL operations:

  • GetComments query (takes task_id).
  • AddComment mutation (takes body, task_id, author_id).

Your plan is locked in. You know what table to create, what permissions to set, and which components to build or modify. In the next step, you will execute this plan by creating the table in Hasura and re-running codegen to see the new types appear automatically.

Migrate the Schema and Re-run Codegen

In the previous step, you planned the comments feature on paper. You identified the new table, its relationships, and which components would change.

Now you get to see the full pipeline repeat. You will create the comments table, configure its permissions, write new GraphQL operations, and re-run codegen. The key insight here is that this process is identical to what you did for tasks. The pipeline is always the same: schema change, then codegen, then frontend update.

In this step, get ready to:
  • Create the comments table and configure its relationships in the Nhost Dashboard.
  • Set up row-level permissions so users can only comment on tasks within their organization.
  • Write GraphQL operations for comments and re-run codegen to generate new typed hooks.
Create the comments table and set up relationships

Your plan from the previous step specified a comments table with five columns. Each comment belongs to a task and has an author. You will create this table the same way you created the tasks table earlier.

  • Open the Nhost Dashboard in your browser at http://localhost:3030.
  • Navigate to the Database section in the left sidebar.
  • Click Create Table.
  • Enter comments as the table name.
  • Add the following columns:
  • id — type uuid, Primary Key, default gen_random_uuid().
  • body — type text, Not Null.
  • task_id — type uuid, Foreign Key referencing tasks.id.
  • author_id — type uuid, Foreign Key referencing auth.users.id.
  • created_at — type timestamptz, default now().
  • Click Create to save the table.

Now configure the relationships so Hasura knows how to join comments with tasks and users.

  • In the comments table settings, go to the Relationships tab.
  • Add an object relationship named task mapping comments.task_id to tasks.id.
  • Add an object relationship named author mapping comments.author_id to auth.users.id.
  • Click Save for each relationship.

Why name relationships explicitly?

When you name a relationship task or author, that exact name becomes a field in your GraphQL schema. This means you can query comment.task.title or comment.author.email directly. Clean relationship names make your GraphQL queries readable.

You should now see the comments table listed in the Database section with two relationships shown.

Don't see the relationships tab?

Make sure you added the foreign key constraints when creating the columns. Without foreign keys, Hasura cannot suggest relationships. If you missed them, edit the task_id and author_id columns and add the foreign key references.

Help me set up relationships on my comments table.

Configure row-level permissions for comments

Permissions ensure users can only read and create comments on tasks that belong to their organization. Without this, any authenticated user could comment on any task across any organization.

  • In the comments table, go to the Permissions tab.
  • For the user role, click the select operation.
  • Set a custom check: the related task's organization_id must equal x-hasura-organization-id.
  • Allow all columns to be visible (id, body, task_id, author_id, created_at).
  • Click Save Permissions.
  • Now click the insert operation for the user role.
  • Set the same custom check: the related task's organization_id must match x-hasura-organization-id.
  • Allow columns: body, task_id, author_id.
  • Click Save Permissions.

How does this permission check work?

The check traverses the relationship you just created. It follows the path comment > task > organization_id and compares it to the user's organization claim in their JWT.

This is the power of row-level security. You do not write this logic in your frontend. Hasura enforces it at the database query level, so even a malicious API request cannot bypass it.

Write GraphQL operations and re-run codegen

With the table and permissions in place, you need to tell codegen what queries and mutations your frontend will use. You will create a new operations file for comments and update the existing tasks query to include comment counts.

  • In VS Code, create a new file at src/graphql/comments.graphql inside the taskboard/frontend directory.
  • Add the following GraphQL operations to comments.graphql:
# Fetch all comments for a specific task, oldest first
query GetComments($task_id: uuid!) {
  comments(where: { task_id: { _eq: $task_id } }, order_by: { created_at: asc }) {
    id
    body
    created_at
    author_id
  }
}

# Create a new comment on a task
mutation AddComment($body: String!, $task_id: uuid!, $author_id: uuid!) {
  insert_comments_one(object: { body: $body, task_id: $task_id, author_id: $author_id }) {
    id
    body
    created_at
    author_id
  }
}

What do these operations do?

GetComments fetches all comments for a given task, sorted by creation time so they appear in conversation order.

AddComment inserts a new comment and returns it. The return fields match what the frontend needs to display the comment immediately without a refetch.

Next, you will update the existing tasks.graphql file so the task list can show how many comments each task has.

  • Open src/graphql/tasks.graphql in VS Code.
  • Find this line inside the GetTasks query:
    created_at
  • Add the following line directly below created_at:
    comments_aggregate { aggregate { count } }

What is comments_aggregate?

Hasura automatically generates aggregate fields for any table with a relationship. Because you linked comments to tasks via a foreign key, Hasura created comments_aggregate on the tasks type. You get a comment count without writing any backend code.

  • Save both comments.graphql and tasks.graphql.

Now re-run codegen to generate typed hooks for these new operations.

  • Make sure your terminal is in the taskboard/frontend directory.
  • Re-run codegen by running this command:
npx graphql-codegen
  • Open src/generated/graphql.tsx and search for useGetCommentsQuery.

You should see four new exports: GetCommentsQuery, AddCommentMutation, useGetCommentsQuery, and useAddCommentMutation. Your existing useGetTasksQuery and useCreateTaskMutation hooks are still there, unchanged.

Why do existing hooks still work?

Codegen is additive. It reads all .graphql files in your src/graphql/ folder and generates everything fresh. New operations add new hooks. Existing operations regenerate the same hooks they had before.

Your TaskList and TaskForm components still compile and run without any changes. The only difference is that GetTasks now returns a comments_aggregate field you can choose to use later.

Codegen fails with a schema error?

If you see an error about comments_aggregate not existing, your Nhost stack may not have picked up the new table yet. Confirm the comments table exists in the Dashboard and that the relationships are saved. Codegen reads the live schema from Hasura, so the table must be fully configured before codegen can see its fields.

Help me fix my codegen error for the comments table.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both comments.graphql and tasks.graphql, and that codegen ran without errors.

ⓧ I'd like to double check the full code

Here is the complete comments.graphql file:

query GetComments($task_id: uuid!) {
  comments(where: { task_id: { _eq: $task_id } }, order_by: { created_at: asc }) {
    id
    body
    created_at
    author_id
  }
}

mutation AddComment($body: String!, $task_id: uuid!, $author_id: uuid!) {
  insert_comments_one(object: { body: $body, task_id: $task_id, author_id: $author_id }) {
    id
    body
    created_at
    author_id
  }
}

Here is the complete tasks.graphql file:

query GetTasks($organization_id: uuid!) {
  tasks(where: { organization_id: { _eq: $organization_id } }) {
    id
    title
    description
    status
    created_at
    comments_aggregate { aggregate { count } }
  }
}

mutation CreateTask($title: String!, $description: String, $organization_id: uuid!, $created_by: uuid!) {
  insert_tasks_one(object: { title: $title, description: $description, organization_id: $organization_id, created_by: $created_by }) {
    id
    title
    description
    status
    created_at
  }
}

You have just watched the full pipeline repeat. Schema change, permissions, new operations, codegen. The exact same process you used for tasks worked identically for comments. Next up, you will use these new hooks to build the comment UI and evolve your existing components.

Evolve the Frontend with Comments

Your schema now has a comments table, your codegen has produced fresh hooks, and your existing task board works end to end. This step is where you experience what real frontend evolution looks like.

You will create new components (CommentList, CommentForm), extract a reusable TaskCard from the existing TaskList, and compose them all together in a TaskDetail page. Along the way you will modify existing components, remove dead code, and notice shared patterns between your data-fetching components.

In this step, get ready to:
  • Create CommentList and CommentForm components using generated hooks.
  • Extract a TaskCard component and update TaskList to use it.
  • Build a TaskDetail page that composes all your components together.
Create CommentList and CommentForm

Your generated hooks already know how to fetch and create comments. Now you need React components that use those hooks to render UI.

  • Create a new file at src/components/CommentList.tsx by running this command from your taskboard/frontend directory:
touch src/components/CommentList.tsx
  • Open src/components/CommentList.tsx in VS Code and paste the following code:
import { useGetCommentsQuery } from '../generated/graphql';

interface CommentListProps {
  taskId: string;
}

// Fetches and displays all comments for a specific task
export default function CommentList({ taskId }: CommentListProps) {
  const { data, loading, error } = useGetCommentsQuery({
    variables: { task_id: taskId },
  });

  if (loading) return <p>Loading comments...</p>;
  if (error) return <p>Error loading comments.</p>;

  const comments = data?.comments || [];

  return (
    <div className="comment-list">
      <h3>Comments ({comments.length})</h3>
      {comments.length === 0 ? (
        <p>No comments yet. Be the first to comment.</p>
      ) : (
        <ul>
          {comments.map((comment) => (
            <li key={comment.id}>
              <p>{comment.body}</p>
              <small>{new Date(comment.created_at).toLocaleString()}</small>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

What does this code do?

The useGetCommentsQuery hook was auto-generated by codegen from your comments.graphql file. It fetches comments filtered by task_id and orders them by creation time.

The component handles three states: loading, error, and success. On success, it maps over the comments array and renders each comment's body and timestamp.

  • Save CommentList.tsx.
  • You won't see a change yet because no page renders this component. The next chunk connects it.
  • Create the comment form file by running:
touch src/components/CommentForm.tsx
  • Open src/components/CommentForm.tsx and paste this code:
import { useState } from 'react';
import { useAddCommentMutation, GetCommentsDocument } from '../generated/graphql';
import { useUserData } from '@nhost/react';

interface CommentFormProps {
  taskId: string;
}

// Form for adding a new comment to a task
export default function CommentForm({ taskId }: CommentFormProps) {
  const [body, setBody] = useState('');
  const user = useUserData();

  // Mutation hook that refetches the comments list after a new comment is added
  const [addComment, { loading }] = useAddCommentMutation({
    refetchQueries: [{ query: GetCommentsDocument, variables: { task_id: taskId } }],
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!body.trim() || !user) return;

    await addComment({
      variables: {
        body: body.trim(),
        task_id: taskId,
        author_id: user.id,
      },
    });

    setBody('');
  };

  return (
    <form onSubmit={handleSubmit} className="comment-form">
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Write a comment..."
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Posting...' : 'Add Comment'}
      </button>
    </form>
  );
}

What does this code do?

useAddCommentMutation is another generated hook. It runs the AddComment mutation you defined in comments.graphql.

The refetchQueries option tells Apollo Client to re-run the GetComments query after the mutation succeeds. This means your CommentList updates automatically when a new comment is posted.

useUserData() from @nhost/react provides the currently logged-in user's ID, which gets passed as the author_id variable.

  • Save CommentForm.tsx.

Seeing TypeScript errors in VS Code?

If VS Code shows red underlines on useGetCommentsQuery or useAddCommentMutation, your codegen output may be stale. Re-run codegen to regenerate:

npx graphql-codegen

help me fix TypeScript errors in my generated GraphQL hooks

Extract TaskCard and update TaskList

Your current TaskList component renders task details inline. To reuse task rendering in the new detail page, you need to extract a standalone TaskCard component.

  • Create the TaskCard file by running:
touch src/components/TaskCard.tsx
  • Open src/components/TaskCard.tsx and paste the following:
import Link from 'next/link';

interface TaskCardProps {
  task: {
    id: string;
    title: string;
    description?: string | null;
    status: string;
    comments_aggregate: { aggregate: { count: number } | null };
  };
}

// Displays a single task with title, status, and comment count
export default function TaskCard({ task }: TaskCardProps) {
  // Extract comment count from the aggregate field
  const commentCount = task.comments_aggregate?.aggregate?.count || 0;

  return (
    <div className="task-card">
      <h3>{task.title}</h3>
      {task.description && <p>{task.description}</p>}
      <span className="status">{task.status}</span>
      <span className="comment-count">{commentCount} comments</span>
      <Link href={`/task/${task.id}`}>View Details</Link>
    </div>
  );
}

What does this code do?

TaskCard receives a single task object as a prop and renders its title, description, status, and comment count. The comment count comes from the comments_aggregate field you added to your GetTasks query earlier.

The Link component from Next.js creates a clickable link to /task/[id]. This is the dynamic route you will create next.

  • Save TaskCard.tsx.

Now update TaskList to use TaskCard instead of rendering inline task details.

  • Open src/components/TaskList.tsx.
  • Replace the entire file contents with this updated version:
import { useGetTasksQuery } from '../generated/graphql';
import TaskCard from './TaskCard';

interface TaskListProps {
  organizationId: string;
}

// Fetches all tasks for the org and renders each as a TaskCard
export default function TaskList({ organizationId }: TaskListProps) {
  const { data, loading, error } = useGetTasksQuery({
    variables: { organization_id: organizationId },
  });

  if (loading) return <p>Loading tasks...</p>;
  if (error) return <p>Error loading tasks.</p>;

  const tasks = data?.tasks || [];

  return (
    <div className="task-list">
      {tasks.length === 0 ? (
        <p>No tasks yet. Create one above.</p>
      ) : (
        tasks.map((task) => (
          <TaskCard key={task.id} task={task} />
        ))
      )}
    </div>
  );
}

What changed here?

The old TaskList rendered task titles and statuses inline. The new version imports TaskCard and delegates all rendering to it.

This is a common evolution pattern: as your UI grows, you extract pieces into dedicated components. The data-fetching logic stays in TaskList, but the presentation moves to TaskCard.

  • Save TaskList.tsx.
  • Refresh your browser and navigate to the dashboard. You should see each task rendered as a card with a title, status, comment count, and a "View Details" link.

Tasks not showing comment counts?

Make sure your tasks.graphql file includes comments_aggregate { aggregate { count } } in the GetTasks query. If you added it after running codegen, re-run npx graphql-codegen to update your types.

help me debug why comment counts are not showing in my TaskCard

Build the TaskDetail page and observe patterns

The final piece is a detail page that composes TaskCard, CommentList, and CommentForm together. This page uses a dynamic route so each task gets its own URL.

  • Create the task detail directory and file by running:
mkdir -p src/pages/task
touch src/pages/task/\[id\].tsx

What is a dynamic route?

In the Next.js pages router, a file named [id].tsx creates a dynamic route. When a user visits /task/abc123, Next.js passes abc123 as router.query.id.

This is how the TaskDetail page knows which task to fetch and display.

  • Open src/pages/task/[id].tsx and paste this code:
import { useRouter } from 'next/router';
import { gql, useQuery } from '@apollo/client';
import TaskCard from '../../components/TaskCard';
import CommentList from '../../components/CommentList';
import CommentForm from '../../components/CommentForm';

// Fetch a single task by its primary key
const GET_TASK = gql`
  query GetTask($id: uuid!) {
    tasks_by_pk(id: $id) {
      id
      title
      description
      status
      comments_aggregate {
        aggregate {
          count
        }
      }
    }
  }
`;

export default function TaskDetail() {
  // Read the task ID from the URL
  const router = useRouter();
  const { id } = router.query;

  const { data, loading, error } = useQuery(GET_TASK, {
    variables: { id },
    skip: !id,
  });

  if (loading || !data) return <p>Loading task...</p>;
  if (error) return <p>Error loading task.</p>;

  const task = data.tasks_by_pk;

  return (
    <div className="task-detail">
      <button onClick={() => router.back()}>Back to tasks</button>
      <TaskCard task={task} />
      <CommentList taskId={task.id} />
      <CommentForm taskId={task.id} />
    </div>
  );
}

What does this page do?

useRouter() from next/router gives you the route parameters. The id comes from the [id].tsx file name convention.

The skip: !id option prevents the query from running before the router has parsed the URL. Once id is available, Apollo fetches the single task using tasks_by_pk (Hasura's auto-generated primary key query).

The page then composes TaskCard, CommentList, and CommentForm. Each component is independent and handles its own data fetching.

  • Save src/pages/task/[id].tsx.
  • Go to your dashboard and click "View Details" on any task.
  • You should see the task details, a comments section (empty at first), and a form to add a comment.
  • Type a comment and click Add Comment. The comment should appear in the list immediately.

Clicking View Details gives a 404?

Make sure your file is at src/pages/task/[id].tsx with the square brackets in the filename. The brackets tell Next.js this is a dynamic route. If you named it id.tsx without brackets, it will only match the literal path /task/id.

Also check that your dev server is still running. New pages require the dev server to detect the new file.

help me fix my dynamic route for the task detail page

Spotting the shared pattern

Look at CommentList and TaskList side by side. Both follow the same structure: call a generated query hook, handle loading/error states, then map over the results to render child components.

This fetch-handle-render pattern is the backbone of every data-displaying component in a GraphQL app. Recognizing it helps you build new features faster because you already know the shape of the code before you write it.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files.

ⓧ I'd like to double check the full code

Here are the complete files you created or modified in this step:

import { useGetCommentsQuery } from '../generated/graphql';

interface CommentListProps {
  taskId: string;
}

// Fetches and displays all comments for a specific task
export default function CommentList({ taskId }: CommentListProps) {
  const { data, loading, error } = useGetCommentsQuery({
    variables: { task_id: taskId },
  });

  if (loading) return <p>Loading comments...</p>;
  if (error) return <p>Error loading comments.</p>;

  const comments = data?.comments || [];

  return (
    <div className="comment-list">
      <h3>Comments ({comments.length})</h3>
      {comments.length === 0 ? (
        <p>No comments yet. Be the first to comment.</p>
      ) : (
        <ul>
          {comments.map((comment) => (
            <li key={comment.id}>
              <p>{comment.body}</p>
              <small>{new Date(comment.created_at).toLocaleString()}</small>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
import { useState } from 'react';
import { useAddCommentMutation, GetCommentsDocument } from '../generated/graphql';
import { useUserData } from '@nhost/react';

interface CommentFormProps {
  taskId: string;
}

// Form for adding a new comment to a task
export default function CommentForm({ taskId }: CommentFormProps) {
  const [body, setBody] = useState('');
  const user = useUserData();

  // Mutation hook that refetches the comments list after a new comment is added
  const [addComment, { loading }] = useAddCommentMutation({
    refetchQueries: [{ query: GetCommentsDocument, variables: { task_id: taskId } }],
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!body.trim() || !user) return;

    await addComment({
      variables: {
        body: body.trim(),
        task_id: taskId,
        author_id: user.id,
      },
    });

    setBody('');
  };

  return (
    <form onSubmit={handleSubmit} className="comment-form">
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Write a comment..."
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Posting...' : 'Add Comment'}
      </button>
    </form>
  );
}
import Link from 'next/link';

interface TaskCardProps {
  task: {
    id: string;
    title: string;
    description?: string | null;
    status: string;
    comments_aggregate: { aggregate: { count: number } | null };
  };
}

// Displays a single task with title, status, and comment count
export default function TaskCard({ task }: TaskCardProps) {
  // Extract comment count from the aggregate field
  const commentCount = task.comments_aggregate?.aggregate?.count || 0;

  return (
    <div className="task-card">
      <h3>{task.title}</h3>
      {task.description && <p>{task.description}</p>}
      <span className="status">{task.status}</span>
      <span className="comment-count">{commentCount} comments</span>
      <Link href={`/task/${task.id}`}>View Details</Link>
    </div>
  );
}
import { useGetTasksQuery } from '../generated/graphql';
import TaskCard from './TaskCard';

interface TaskListProps {
  organizationId: string;
}

// Fetches all tasks for the org and renders each as a TaskCard
export default function TaskList({ organizationId }: TaskListProps) {
  const { data, loading, error } = useGetTasksQuery({
    variables: { organization_id: organizationId },
  });

  if (loading) return <p>Loading tasks...</p>;
  if (error) return <p>Error loading tasks.</p>;

  const tasks = data?.tasks || [];

  return (
    <div className="task-list">
      {tasks.length === 0 ? (
        <p>No tasks yet. Create one above.</p>
      ) : (
        tasks.map((task) => (
          <TaskCard key={task.id} task={task} />
        ))
      )}
    </div>
  );
}
import { useRouter } from 'next/router';
import { gql, useQuery } from '@apollo/client';
import TaskCard from '../../components/TaskCard';
import CommentList from '../../components/CommentList';
import CommentForm from '../../components/CommentForm';

// Fetch a single task by its primary key
const GET_TASK = gql`
  query GetTask($id: uuid!) {
    tasks_by_pk(id: $id) {
      id
      title
      description
      status
      comments_aggregate {
        aggregate {
          count
        }
      }
    }
  }
`;

export default function TaskDetail() {
  // Read the task ID from the URL
  const router = useRouter();
  const { id } = router.query;

  const { data, loading, error } = useQuery(GET_TASK, {
    variables: { id },
    skip: !id,
  });

  if (loading || !data) return <p>Loading task...</p>;
  if (error) return <p>Error loading task.</p>;

  const task = data.tasks_by_pk;

  return (
    <div className="task-detail">
      <button onClick={() => router.back()}>Back to tasks</button>
      <TaskCard task={task} />
      <CommentList taskId={task.id} />
      <CommentForm taskId={task.id} />
    </div>
  );
}

You just experienced the three modes of frontend evolution: creating new components, modifying existing ones, and removing old inline code. Next up, you will extend an existing table and see how that change ripples through your stack.

Add Task Assignment

In the last step, you added a brand new table (comments) and saw how the full pipeline repeats from scratch. This time, the change is different. Instead of a new table, you are adding a new column to an existing one.

This teaches you the second type of schema evolution: modifying something that already exists. The ripple through codegen into your existing components is smaller, but it still requires updating queries, re-running codegen, and changing your UI.

In this step, get ready to:
  • Add an assigned_to column and relationship to the tasks table.
  • Update your GraphQL queries, add an AssignTask mutation, and re-run codegen.
  • Update TaskCard and TaskForm to display and set the assignee.
Add the assigned_to column and relationship

When you added comments, you created a whole new table with its own relationships and permissions. Modifying an existing table is a lighter operation, but the downstream effects still matter.

  • Open the Nhost Dashboard at http://localhost:3030 in your browser.
  • Navigate to the Database section and select the tasks table.
  • Click Modify to edit the table structure.
  • Add a new column with these settings:
  • Name: assigned_to.
  • Type: uuid.
  • Nullable: checked (a task can exist without an assignee).
  • Click Save to add the column.
  • Now add a foreign key constraint on assigned_to that references auth.users.id.
  • Navigate to the Relationships tab for the tasks table.
  • Add a new object relationship named assignee that maps assigned_to to auth.users.id.

Why is this different from adding the comments table?

When you added comments, you needed a new table, new permissions, new queries, new mutations, and entirely new components. Modifying an existing table is lighter: you add a column, update an existing query to include the new field, and update the components that already render that data.

The ripple is smaller, but it still touches every layer of the chain. The next substeps will show you exactly which layers need updating.

Verify the column exists by checking the tasks table in the Nhost Dashboard. You should see assigned_to listed as a nullable uuid column.

Don't see the column?

Make sure you clicked Save after adding the column. If the foreign key constraint fails, check that the type is uuid (matching the type of auth.users.id).

Help me debug why my foreign key constraint failed on the assigned_to column.

Update your GraphQL operations and re-run codegen

The column exists in the database and Hasura has already exposed it in the GraphQL API. Now you need to tell your frontend queries to actually fetch and use the new field.

  • In VS Code, open src/graphql/tasks.graphql inside your taskboard/frontend folder.
  • Replace the entire file contents with the updated queries and mutations below:
query GetTasks($organization_id: uuid!) {
  tasks(where: { organization_id: { _eq: $organization_id } }) {
    id
    title
    description
    status
    assigned_to
    created_at
    comments_aggregate { aggregate { count } }
  }
}

mutation CreateTask($title: String!, $description: String, $organization_id: uuid!, $created_by: uuid!, $assigned_to: uuid) {
  insert_tasks_one(object: { title: $title, description: $description, organization_id: $organization_id, created_by: $created_by, assigned_to: $assigned_to }) {
    id
    title
    description
    status
    assigned_to
    created_at
  }
}

mutation AssignTask($task_id: uuid!, $assigned_to: uuid) {
  update_tasks_by_pk(pk_columns: { id: $task_id }, _set: { assigned_to: $assigned_to }) {
    id
    assigned_to
  }
}

What changed in this file?

  • The GetTasks query now fetches the assigned_to field so your UI can display who is assigned to each task.
  • The CreateTask mutation now accepts an optional $assigned_to variable, so you can assign someone at creation time.
  • The new AssignTask mutation uses update_tasks_by_pk to change the assignee on an existing task.
  • Save tasks.graphql.
  • Re-run codegen to regenerate your typed hooks by running this command in your terminal (from the taskboard/frontend directory):
npx graphql-codegen

You should see codegen complete successfully. Open src/generated/graphql.tsx and search for assigned_to. You will find the field in your existing task types and a brand new useAssignTaskMutation hook.

Notice the difference from the comments step

When you added the comments table, codegen produced entirely new types and hooks. This time, your existing GetTasksQuery type simply gained a new field. Your existing hooks still work. The new useAssignTaskMutation hook is the only truly new addition.

Codegen failing?

If codegen reports an unknown field error for assigned_to, make sure the column was tracked in Hasura. Open the Hasura Console and verify that the assigned_to column appears in the tasks table's tracked columns.

Help me debug why codegen can't find the assigned_to field.

Update TaskCard and TaskForm to use the new field

Your types are updated and your hooks know about assigned_to. Now update the components that render and create tasks.

  • Open src/components/TaskCard.tsx.
  • Add a line that displays the assigned_to field. Since the relationship gives you a user ID, display it as "Assigned to: [user id]" or "Unassigned" if null.
// Inside your TaskCard component, add this below the status display:
<p>{task.assigned_to ? `Assigned to: ${task.assigned_to}` : 'Unassigned'}</p>

Why show the raw user ID?

In a production app, you would resolve the user ID to a display name. For this project, displaying the ID proves the data flows correctly through the chain. You can see it change when you assign different users.

  • Save TaskCard.tsx.
  • Now open src/components/TaskForm.tsx.
  • Add a dropdown (select element) that lists the members of the current user's organization. You can query the user_organizations table to get the list of users in the same org.
  • Update the useCreateTaskMutation call to pass the selected assigned_to value from the dropdown.
// Add state for the assignee selection
const [assignedTo, setAssignedTo] = useState<string | null>(null);

// Add a select dropdown in your form JSX
<select
  value={assignedTo || ''}
  onChange={(e) => setAssignedTo(e.target.value || null)}
>
  <option value="">Unassigned</option>
  {/* Map org members here */}
</select>

// Update your mutation call to include assigned_to
await createTask({
  variables: {
    title,
    description,
    organization_id: orgId,
    created_by: userId,
    assigned_to: assignedTo,
  },
});

What does this code do?

  • The assignedTo state holds the selected user's ID (or null for unassigned).
  • The dropdown defaults to "Unassigned" and lists org members as options.
  • The mutation now passes assigned_to as a variable. Since the GraphQL variable type is uuid (nullable), passing null leaves the task unassigned.
  • Save TaskForm.tsx.
  • Refresh your browser and navigate to the dashboard.
  • Create a new task and select an assignee from the dropdown.
  • Verify that the TaskCard displays the assigned user's ID next to the task.

Assigned user not appearing on the task card?

Check that your GetTasks query includes assigned_to in the selection set. If you forgot to add it, the field will be undefined in your component even though the database has the value.

Also verify you re-ran npx graphql-codegen after updating the query file. Without re-running codegen, your TypeScript types will not include the new field.

Help me figure out why assigned_to is not showing up in my task cards.

✔️ Awesome, I've got everything!

Great. Double check you have saved both TaskCard.tsx and TaskForm.tsx.

ⓧ I'd like to double check the full code

Here is the complete tasks.graphql file you should have at this point:

query GetTasks($organization_id: uuid!) {
  tasks(where: { organization_id: { _eq: $organization_id } }) {
    id
    title
    description
    status
    assigned_to
    created_at
    comments_aggregate { aggregate { count } }
  }
}

mutation CreateTask($title: String!, $description: String, $organization_id: uuid!, $created_by: uuid!, $assigned_to: uuid) {
  insert_tasks_one(object: { title: $title, description: $description, organization_id: $organization_id, created_by: $created_by, assigned_to: $assigned_to }) {
    id
    title
    description
    status
    assigned_to
    created_at
  }
}

mutation AssignTask($task_id: uuid!, $assigned_to: uuid) {
  update_tasks_by_pk(pk_columns: { id: $task_id }, _set: { assigned_to: $assigned_to }) {
    id
    assigned_to
  }
}

You have now experienced both types of schema evolution: adding a new table and modifying an existing one. The pattern is the same (schema change, codegen, UI update), but the scale is different. That is the key takeaway for real projects. You always know the steps, but the size of the ripple depends on whether you are extending or modifying.

Secret mission

Add Task Priority with Drag-and-Drop Reordering

Your task board works, but tasks sit in the order they were created. Real task boards let you drag tasks into priority order. Can you add a priority field and implement drag-and-drop reordering with instant optimistic updates?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Nhost Docker containers (Postgres, Hasura, Auth, Storage, Dashboard, Mailhog).
  • Local Postgres database with your tables (organizations, users, tasks, comments).
  • Next.js project folder with all frontend code.
  • Node.js packages (Apollo Client, Nhost SDK, GraphQL Codegen, dnd-kit).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue working on the project right away.

  • Your Nhost stack stays running in Docker and is accessible at the local dashboard URL.
  • Your Next.js dev server stays running if you left it active in the terminal.
  • Docker Desktop continues using system memory while the containers are running.

Pause - I'll come back to this later

Shut down the running containers to free up memory, but keep all your files and database data intact for next time.

  • Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
  • Stop the Nhost stack by running:
nhost down
  • Your project folder and all code remain on disk.
  • Your database data is preserved in Docker volumes. Next time you run nhost up, everything comes back as you left it.
  • Optionally quit Docker Desktop from the menu bar to free additional memory.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database data and project files.

  • Stop the Nhost stack if it is still running:
nhost down
  • Remove the Docker volumes that hold your database data by running:
docker volume prune
  • Delete your project folder from the Desktop.
  • Optionally uninstall the Nhost CLI if you do not plan to use it for other projects.
  • Optionally quit Docker Desktop or uninstall it if you no longer need it.

That's a Wrap!

That's a Wrap!

Nice work! You built a full-stack multi-tenant task board from scratch, evolved it twice with new features, and learned how to trace errors across every layer of the stack.

You've learned how to:

  • Build and trace the full chain from Database to Hasura to GraphQL API to Codegen to Typed Hooks to React Components to Screen, knowing exactly which layer to investigate when something breaks.
  • Set up multi-tenant authentication with Nhost Auth and row-level permissions so each organization only sees its own data.
  • Evolve a running application by adding a new table (comments), modifying an existing table (task assignment), and updating the frontend to match. You experienced the full cycle of schema change > codegen > component update that professional teams repeat daily.
  • Secret Mission: Added drag-and-drop task priority with optimistic UI updates using Apollo Client so reordering feels instant.

Ready to quiz yourself?