Build a Multi-Tenant Task Board

Build a task board with GraphQL, Hasura, and Next.js App Router.

Introduction

30 Second Summary

Every time you log into a tool like Slack or Notion, you only see your company's data. Behind the scenes, dozens of companies share the same database. The system just knows who you are and filters everything accordingly.

In this project, you will build a multi-tenant task board where two fictional companies share one database but only ever see their own tasks. You will wire up the full stack from Docker containers to a type-safe GraphQL frontend.

What You'll Build

A task management board with tenant switching, powered by Next.js, Hasura GraphQL Engine, Apollo Client, and GraphQL Codegen.

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

  • A Docker Compose stack running Hasura and PostgreSQL locally, with row-level permissions that enforce complete tenant isolation.
  • A Next.js App Router project with route groups, layouts, and design tokens that follow professional folder structure patterns.
  • A fully typed GraphQL Codegen workflow that auto-generates hooks from your Hasura schema, giving you compile-time safety on every query and mutation.
  • Secret Mission: wire up real authentication with NextAuth.js and Hasura JWT to replace the mock tenant switcher.

Are there any prerequisites?

You should have basic familiarity with React and TypeScript syntax. You also need Docker Desktop installed and running on your machine. Everything else gets installed during the project.

Set Up Docker Compose with Hasura and PostgreSQL

A multi-tenant task board needs two backend services: a database to store data and a GraphQL API to serve it. Instead of installing each service individually, you can define both in a single configuration file and start them together.

In this step, you will use Docker Compose to run PostgreSQL (your database) and Hasura GraphQL Engine (your GraphQL API layer) as local containers. By the end, you will have a fully working GraphQL endpoint running at localhost:8080.

In this step, get ready to:
  • Create the project directory and write the Docker Compose configuration file.
  • Start both containers and verify they are running.
  • Connect the database in the Hasura Console and test the GraphQL endpoint.
Create the project directory and Docker Compose file

Docker Compose reads a docker-compose.yml file that describes which containers to run, how they connect to each other, and what environment variables they need. You will create this file inside a new project folder on your Desktop.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • 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
  • Confirm the folder exists by running:
ls

You should see the taskboard folder listed in the output if you ran ls before the cd, or an empty directory listing if you ran it after. Either confirms the folder was created.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the taskboard folder on your Desktop, then click Open.
  • Create a new file by clicking File then New File.
  • Save it immediately with Cmd+S (macOS) or Ctrl+S (Windows) and name it docker-compose.yml.
  • Paste the following configuration into docker-compose.yml:
version: '3.6'
services:
  postgres:
    image: postgres:16
    restart: always
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: postgrespassword
      POSTGRES_DB: taskboard

  graphql-engine:
    image: hasura/graphql-engine:v2.48.16
    platform: linux/arm64
    ports:
      - '8080:8080'
    depends_on:
      - postgres
    restart: always
    environment:
      HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/taskboard
      PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/taskboard
      HASURA_GRAPHQL_ENABLE_CONSOLE: 'true'
      HASURA_GRAPHQL_DEV_MODE: 'true'
      HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
      HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret
      HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous

volumes:
  db_data:

What does this configuration do?

This file defines two services that Docker will run as containers:

  • postgres runs PostgreSQL 16 with a database called taskboard. The volumes section persists your data even if the container restarts.
  • graphql-engine runs Hasura v2.48.16 and connects to the Postgres container using the PG_DATABASE_URL environment variable.
  • HASURA_GRAPHQL_ADMIN_SECRET protects the console with a password. HASURA_GRAPHQL_ENABLE_CONSOLE gives you a web UI at port 8080.
  • platform: linux/arm64 ensures the Hasura image runs correctly on Apple Silicon Macs.
  • Save docker-compose.yml with Cmd+S (macOS) or Ctrl+S (Windows).

Seeing YAML syntax errors?

YAML is very sensitive to indentation. Make sure you are using spaces (not tabs) and that each level is indented by exactly 2 spaces. Check that services: is at the top level and each service name is indented once.

help me fix YAML indentation errors in my docker-compose.yml

✔️ Awesome, I've got everything!

Great. Make sure you have saved docker-compose.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file for comparison:

version: '3.6'
services:
  postgres:
    image: postgres:16
    restart: always
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: postgrespassword
      POSTGRES_DB: taskboard

  graphql-engine:
    image: hasura/graphql-engine:v2.48.16
    platform: linux/arm64
    ports:
      - '8080:8080'
    depends_on:
      - postgres
    restart: always
    environment:
      HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/taskboard
      PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/taskboard
      HASURA_GRAPHQL_ENABLE_CONSOLE: 'true'
      HASURA_GRAPHQL_DEV_MODE: 'true'
      HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
      HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret
      HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous

volumes:
  db_data:
Start the containers and open the Hasura Console

With the configuration file saved, you can now tell Docker to pull the images and start both containers in the background.

  • In your terminal, make sure you are inside the taskboard folder (the one containing docker-compose.yml).
  • Start the containers by running this command:
docker compose up -d

What does this command do?

docker compose up reads your docker-compose.yml and starts all the services defined in it. The -d flag runs them in detached mode, meaning they run in the background so you can keep using your terminal.

Docker will download the PostgreSQL and Hasura images on the first run. This may take 1-2 minutes depending on your internet speed.

  • Verify both containers are running by running:
docker compose ps

You should see two services listed with a State of running (or Up): postgres and graphql-engine.

Container not starting or showing 'exited'?

  • Make sure Docker Desktop is running. You should see the Docker icon in your menu bar or system tray.
  • If graphql-engine shows as exited, it may have started before Postgres was ready. Run docker compose down followed by docker compose up -d to restart both.
  • On Intel Macs or Linux, remove the platform: linux/arm64 line from docker-compose.yml if you see a platform mismatch error.

help me troubleshoot Docker Compose containers that won't start

  • Open your browser and navigate to:
http://localhost:8080/console

The Hasura Console will prompt you for an admin secret. Enter myadminsecret and click Enter.

You should now see the Hasura Console dashboard with tabs for API, Data, Actions, and more.

  • Click the Data tab in the top navigation bar.
  • You should see a Connect Database screen. Click Connect Existing Database.
  • Enter default in the Database Display Name field.
  • Under Connect Database Via, select Environment Variable.
  • Enter PG_DATABASE_URL in the environment variable name field.
  • Click Connect Database.

Why use an environment variable?

You defined PG_DATABASE_URL in your docker-compose.yml with the full Postgres connection string. Hasura already has access to this variable inside its container. By referencing the variable name instead of pasting the raw connection string, you avoid duplicating secrets.

After clicking Connect Database, you should see a confirmation that the database was successfully connected. The Data tab now shows your default database in the left sidebar.

Connection failed error?

  • Double check that the environment variable name is exactly PG_DATABASE_URL (all caps, underscores, no spaces).
  • Make sure both containers are still running. Go back to your terminal and run docker compose ps to verify.

help me connect my PostgreSQL database to Hasura using PG_DATABASE_URL

Test the GraphQL endpoint with an introspection query

GraphQL supports a feature called introspection that lets you ask the API about its own schema. Running an introspection query is the fastest way to confirm that Hasura is connected to your database and ready to serve requests.

  • In the Hasura Console, click the API tab in the top navigation bar.
  • You should see the GraphiQL explorer with a query editor on the left and a results panel on the right.
  • Clear any existing content in the query editor and paste the following introspection query:
{
  __schema {
    queryType {
      fields {
        name
      }
    }
  }
}
  • Click the Play button (the triangle icon) to execute the query.

You should see a JSON response on the right panel listing available query fields. It will include system-level fields like no_queries_available since you have not created any tables yet. The important thing is that you received a successful response, not an error.

Seeing an error instead of a response?

  • Make sure the admin secret header is set. Look for x-hasura-admin-secret in the Request Headers section below the query editor. If it is missing, add it with the value myadminsecret.
  • If you see a network error, check that port 8080 is not in use by another application.

help me fix my Hasura GraphQL introspection query that returns an error

Your local GraphQL backend is now fully operational. Next up, you will design the multi-tenant database schema with tables for organizations, users, and tasks.

Design the Multi-Tenant Database Schema

Your Hasura instance is up and running. Now you need the database tables that will hold your multi-tenant data.

The core challenge of multi-tenancy is data isolation. Two companies share the same database, but neither should ever see the other's data. The schema you build here makes that possible by linking every record back to an organization through foreign keys.

In this step, get ready to:
  • Create the organizations and users tables with seed data for two tenants.
  • Create the tasks table with foreign keys linking to organizations and users.
  • Track all tables in Hasura and configure relationships between them.
Create the organizations and users tables

Every multi-tenant app needs a way to know which organization owns a piece of data. You will create an organizations table as the top-level entity, then a users table where each user belongs to exactly one organization.

Instead of clicking through the UI to create each column one by one, you will use the SQL runner in the Hasura Console to create both tables and insert seed data in one go.

  • Open the Hasura Console at http://localhost:8080/console.
  • Click the Data tab in the top navigation.
  • Click SQL in the left sidebar to open the SQL runner.
  • Paste the following SQL into the editor and click Run! to execute it:
-- Create the organizations table (top-level tenant entity)
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL
);

-- Create the users table (each user belongs to one organization)
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  organization_id UUID NOT NULL REFERENCES organizations(id)
);

-- Insert two organizations as seed data
INSERT INTO organizations (name, slug) VALUES
  ('Acme Corp', 'acme'),
  ('Stark Industries', 'stark');

-- Insert one user per organization
INSERT INTO users (name, email, organization_id) VALUES
  ('Alice Johnson', 'alice@acme.com', (SELECT id FROM organizations WHERE slug = 'acme')),
  ('Tony Stark', 'tony@stark.com', (SELECT id FROM organizations WHERE slug = 'stark'));

What does this SQL do?

The organizations table stores each tenant. The slug column provides a URL-friendly identifier, and the UNIQUE constraint ensures no two organizations share one.

The users table uses a foreign key (organization_id) to link each user to their organization. The seed subqueries look up the organization ID by slug so you do not need to hard-code UUIDs.

  • After clicking Run!, you should see a green success message at the bottom of the SQL editor.
  • Click Data in the left sidebar to return to the table list.
  • You should see organizations and users listed under the public schema in the left sidebar.

Don't see the tables listed?

If the SQL runner shows an error about gen_random_uuid() not existing, make sure your Docker container is running PostgreSQL 16. Earlier versions may require the pgcrypto extension.

If the tables were created but are not visible in the sidebar, you may need to track them manually. Click the table name in the left sidebar and select Track. Tables created via the SQL runner sometimes need to be tracked manually.

Why aren't my tables showing up after running SQL?

What is multi-tenancy?

Multi-tenancy means multiple customers (tenants) share the same application and database. Each tenant's data is isolated so they cannot see each other's records.

The approach you are using here is called "shared database with row-level isolation". Every tenant-scoped table includes an organization_id column, and Hasura's permission system filters rows by that column at query time.

Create the tasks table and insert sample data

The tasks table is the main content of your task board. Each task belongs to an organization and has a creator. The status column tracks whether a task is in the to-do, in-progress, or done state.

  • In the Hasura Console, click SQL in the left sidebar to open the SQL runner again.
  • Clear the editor, paste the following SQL, and click Run!:
-- Create the tasks table (scoped to organizations, linked to users)
CREATE TABLE tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  description TEXT,
  status TEXT NOT NULL DEFAULT 'todo',
  organization_id UUID NOT NULL REFERENCES organizations(id),
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Insert sample tasks for Acme Corp
INSERT INTO tasks (title, description, status, organization_id, created_by) VALUES
  ('Design landing page', 'Create wireframes for the new marketing site', 'todo',
    (SELECT id FROM organizations WHERE slug = 'acme'),
    (SELECT id FROM users WHERE email = 'alice@acme.com')),
  ('Set up CI pipeline', 'Configure GitHub Actions for automated testing', 'in_progress',
    (SELECT id FROM organizations WHERE slug = 'acme'),
    (SELECT id FROM users WHERE email = 'alice@acme.com')),
  ('Write API docs', 'Document all REST endpoints for v2', 'done',
    (SELECT id FROM organizations WHERE slug = 'acme'),
    (SELECT id FROM users WHERE email = 'alice@acme.com'));

-- Insert sample tasks for Stark Industries
INSERT INTO tasks (title, description, status, organization_id, created_by) VALUES
  ('Build arc reactor v3', 'Improve energy output by 40%', 'in_progress',
    (SELECT id FROM organizations WHERE slug = 'stark'),
    (SELECT id FROM users WHERE email = 'tony@stark.com')),
  ('Upgrade suit firmware', 'Patch thermal regulation module', 'todo',
    (SELECT id FROM organizations WHERE slug = 'stark'),
    (SELECT id FROM users WHERE email = 'tony@stark.com'));

What does this SQL do?

The tasks table has two foreign keys: organization_id links to the owning organization, and created_by links to the user who created the task.

The status column defaults to todo and uses plain text values (todo, in_progress, done) that the frontend will use for column grouping. The sample data gives each organization a mix of statuses so you can test the board layout later.

  • Confirm you see a green success message after running the SQL.
  • Click public in the left sidebar. You should now see tasks listed alongside organizations and users.

Seeing a foreign key error?

If the SQL fails with a foreign key constraint violation, the seed data from the previous step may not have been inserted correctly. Go back to the SQL runner and run SELECT * FROM organizations; and SELECT * FROM users; to verify data exists.

My tasks table SQL is failing with a constraint error. How do I debug this?

Track tables and configure relationships

Hasura needs to know about the connections between your tables to enable nested GraphQL queries. For example, querying a task and getting the creator's name in one request requires a relationship between tasks and users.

Because you defined foreign keys in your SQL, Hasura can automatically suggest the correct relationships. You just need to track the tables and accept the suggestions.

  • In the Hasura Console, click the Data tab.
  • If any tables show as "Untracked", click the Track All button to expose them via the GraphQL API.

What does tracking a table mean?

A table can exist in PostgreSQL without being exposed through GraphQL. "Tracking" a table tells Hasura to generate GraphQL queries, mutations, and subscriptions for it. Tables created via the Console UI are tracked automatically, but tables created via raw SQL need to be tracked manually.

Now configure the relationships. Hasura detects foreign keys and suggests relationships for you.

  • Click on the organizations table in the left sidebar.
  • Click the Relationships tab.
  • Hasura will suggest two array relationships. Click Track All (or add them individually):
  • An array relationship named users (organizations.id to users.organization_id).
  • An array relationship named tasks (organizations.id to tasks.organization_id).
  • Click on the users table in the left sidebar.
  • Click the Relationships tab.
  • Track the suggested object relationship named organization (users.organization_id to organizations.id).
  • Click on the tasks table in the left sidebar.
  • Click the Relationships tab.
  • Track the suggested object relationship named organization (tasks.organization_id to organizations.id).
  • Track the suggested object relationship named user (tasks.created_by to users.id).

Object vs array relationships

An object relationship means "this row points to one other row" (many-to-one). A task has one creator, so tasks.user is an object relationship.

An array relationship means "many rows point back to this row" (one-to-many). An organization has many tasks, so organizations.tasks is an array relationship.

Verify the relationships are working by running a nested query.

  • Click the API tab in the top navigation to open the GraphiQL explorer.
  • Paste the following query into the editor and click the play button:
query {
  organizations {
    name
    slug
    users {
      name
      email
    }
    tasks {
      title
      status
    }
  }
}
  • You should see both organizations returned, each with their nested users and tasks.

What should I see?

The response should show Acme Corp with Alice Johnson and three tasks, and Stark Industries with Tony Stark and two tasks. If you see empty arrays for users or tasks, a relationship was not tracked correctly.

Getting empty arrays or a relationship error?

Go back to each table's Relationships tab and confirm all suggested relationships are tracked (not still showing a Track button). The organizations table should have two array relationships (users and tasks). The users table should have one object relationship (organization). The tasks table should have two object relationships (organization and user).

My GraphQL query is returning empty arrays for relationships. How do I fix this?

Your database schema is complete. You have two organizations, their users, and sample tasks all linked together through foreign keys. Next, you will configure Hasura's permission system to enforce data isolation between tenants at the query level.

Configure Hasura Row-Level Permissions for Multi-Tenancy

Your database has organizations, users, and tasks all living in the same tables. Right now, anyone with the admin secret can see everything. In a real multi-tenant app, that is a disaster waiting to happen.

In this step, you will configure Hasura's row-level permission system to enforce complete data isolation between tenants. Each query will automatically filter results based on the requesting user's organization, without writing a single line of backend code.

In this step, get ready to:
  • Create a `user` role with select permissions on the organizations table.
  • Set up full CRUD permissions on the tasks table scoped by organization.
  • Test the permissions by switching between tenant headers in the Hasura Console.
Create the user role with organizations permissions

Hasura uses roles and session variables to enforce row-level permissions. You will create a user role that restricts every query based on the X-Hasura-Org-Id session variable sent with each request.

  • In the Hasura Console, click the **Data** tab in the top navigation bar.
  • Click on the organizations table in the left sidebar.
  • Click the Permissions tab.
  • In the Enter new role field at the bottom of the roles row, type user and press Enter.
  • Click the select cell for the user role to open the permissions editor.

The permissions editor opens with a row filter section and a column selection section.

  • Under Row select permissions, select With custom check.
  • Enter the following JSON filter:
{"id": {"_eq": "X-Hasura-Org-Id"}}
  • Under Column select permissions, check the boxes for id, name, and slug.
  • Click Save Permissions.

What does this permission do?

The filter {"id": {"_eq": "X-Hasura-Org-Id"}} tells Hasura: only return rows where the id column matches the X-Hasura-Org-Id value sent in the request headers.

This means a user from Acme Corp can only ever see the Acme Corp organization row. They cannot see Stark Industries, even if they try to query for it directly.

What are session variables in Hasura?

Session variables are values that come from request headers prefixed with X-Hasura-. In production, these are extracted from a JWT token. For now, you will send them manually as headers to simulate different tenants.

Set up tasks table permissions for the user role

The tasks table needs full CRUD permissions. Every operation will use the same organization filter to ensure users only interact with their own organization's tasks.

  • In the left sidebar, click on the tasks table.
  • Click the Permissions tab.
  • Click the select cell for the user role.
  • Under Row select permissions, select With custom check and enter this filter:
{"organization_id": {"_eq": "X-Hasura-Org-Id"}}
  • Under Column select permissions, click Toggle All to allow all columns.
  • Click Save Permissions.

Now set up the insert permission. This one has an extra feature: column presets that automatically fill in the organization and user IDs.

  • Click the insert cell for the user role.
  • Under Row insert permissions, select With custom check and enter the same filter:
{"organization_id": {"_eq": "X-Hasura-Org-Id"}}
  • Under Column insert permissions, check title, description, and status.
  • Scroll down to the Column presets section.
  • For the organization_id column, select from session variable and enter X-Hasura-Org-Id.
  • For the created_by column, select from session variable and enter X-Hasura-User-Id.
  • Click Save Permissions.

What are column presets?

Column presets automatically inject values into new rows. When a user creates a task, they only provide the title and description. Hasura fills in organization_id and created_by from the session variables.

This prevents a malicious user from inserting a task into another organization's data, even if they try to pass a different organization_id in the mutation.

Now add the update and delete permissions using the same pattern.

  • Click the update cell for the user role.
  • Under Pre-update check, select With custom check and enter:
{"organization_id": {"_eq": "X-Hasura-Org-Id"}}
  • Under Column update permissions, check title, description, and status.
  • Click Save Permissions.
  • Click the delete cell for the user role.
  • Under Row delete permissions, select With custom check and enter the same filter:
{"organization_id": {"_eq": "X-Hasura-Org-Id"}}
  • Click Save Permissions.

You should now see green checkmarks across the select, insert, update, and delete columns for the user role on the tasks table.

Don't see the user role on the tasks table?

If the user role does not appear on the tasks table, make sure you saved the organizations permission first. Hasura creates the role globally once you save it on any table.

help me troubleshoot my Hasura permissions setup

Test permissions by switching tenant headers

The real proof is seeing different data come back for different tenants. You will simulate two different users by manually setting session variable headers in the Hasura Console's API tab.

  • Click the API tab in the Hasura Console top navigation.
  • In the Request Headers section below the query editor, remove the existing x-hasura-admin-secret header.
  • Add three new headers:
  • X-Hasura-Role with value user.
  • X-Hasura-Org-Id with the UUID of your Acme Corp organization (copy it from the Data tab by browsing the organizations table).
  • X-Hasura-User-Id with the UUID of your Acme Corp user (from the users table).
  • Enter the following query in the GraphiQL editor:
query GetTasks {
  tasks {
    id
    title
    status
    organization_id
  }
}
  • Click the play button to run the query.

You should only see tasks that belong to Acme Corp. Every returned row will have the same organization_id matching the Acme Corp UUID you provided in the header.

  • Now change the X-Hasura-Org-Id header to the Stark Industries organization UUID.
  • Change the X-Hasura-User-Id header to the Stark Industries user UUID.
  • Run the same query again.

The results switch completely. You now see only Stark Industries tasks. The same query, same endpoint, same table. The only thing that changed was the headers.

Why is this powerful?

You did not write a single WHERE clause in your application code. Hasura injects the filter at the database level before the query runs. This means your frontend developers can write simple, clean queries and the permission layer handles tenant isolation automatically.

Even if a bug in the frontend accidentally omits a filter, the data stays isolated. Permissions are enforced server-side by Hasura, not by client-side query construction.

Seeing an empty result or an error?

If you see an empty array, double-check that the UUID you pasted in the X-Hasura-Org-Id header matches exactly one of the UUIDs in your organizations table. A trailing space or missing character will return no results.

If you see a permission error, make sure you removed the x-hasura-admin-secret header. Hasura uses the admin secret for unrestricted access and ignores role-based headers when both are present.

help me debug my Hasura permission test

Your database now enforces complete tenant isolation at the permission layer. Next up, you will scaffold the Next.js frontend and learn how the App Router organizes files into route groups, layouts, and pages.

Scaffold the Next.js Project and Configure the Folder Structure

Your backend is fully operational. Hasura is serving a GraphQL API on top of PostgreSQL, and row-level permissions enforce complete tenant isolation. Now you need a frontend to consume that API.

In this step, you will scaffold a Next.js project using the App Router pattern. You will set up route groups for clean URL structures, define design tokens for consistent styling, and prepare the folder layout that the rest of the project builds on.

In this step, get ready to:
  • Scaffold a Next.js project with TypeScript, Tailwind CSS, and the App Router.
  • Create route groups for dashboard and auth sections without affecting URL paths.
  • Configure design tokens using Tailwind's @theme directive for a consistent look.
Scaffold the Next.js project

Next.js provides a scaffolding tool called create-next-app that generates a complete project structure with sensible defaults. You will use flags to pre-configure TypeScript, Tailwind CSS, and the App Router in one command.

  • Make sure your terminal is in the directory that contains your docker-compose.yml file.
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest taskboard --typescript --tailwind --eslint --app --src-dir --no-import-alias

What do these flags do?

  • --typescript enables TypeScript for type safety.
  • --tailwind installs Tailwind CSS for utility-first styling.
  • --eslint adds ESLint for catching code issues early.
  • --app uses the App Router instead of the older Pages Router.
  • --src-dir puts application code inside a src/ folder for cleaner organization.
  • --no-import-alias skips the default @/ import alias prompt (the scaffold still configures it for you).
  • Wait for the installation to complete. You should see a success message and a new taskboard folder in your project directory.
  • Move into the new project folder by running:
cd taskboard
  • Confirm the scaffold worked by running:
ls src/app
  • You should see globals.css, layout.tsx, and page.tsx listed in the output.

Don't see these files?

Make sure you ran the command from the directory containing docker-compose.yml. The taskboard folder should be a sibling of that file.

If npx is not found, confirm Node.js is installed by running node --version.

Why isn't my Next.js project scaffolding correctly?

What did the scaffold create?

Here is what the key files do in a Next.js App Router project:

  • src/app/layout.tsx is the root layout. It wraps every page in shared UI like fonts and providers.
  • src/app/page.tsx is the homepage content rendered at the / route.
  • src/app/globals.css holds global styles and Tailwind configuration.
  • next.config.ts controls Next.js build and runtime settings.
  • tsconfig.json configures TypeScript compilation and path aliases.
Create the route group structure

In the App Router, folders inside src/app/ become URL segments by default. Route groups let you organize files into logical sections without adding those folder names to the URL. A folder wrapped in parentheses like (dashboard) shares a layout among its children but the word "dashboard" never appears in the browser address bar.

  • Create the route group folders and placeholder files by running these commands:
mkdir -p src/app/\(dashboard\)/tasks
mkdir -p src/app/\(auth\)/login

Why the backslashes?

Parentheses have special meaning in the terminal shell. The backslashes escape them so the shell treats them as literal characters in the folder name.

  • Confirm the folders exist by running:
ls src/app
  • You should see (auth) and (dashboard) alongside the existing globals.css, layout.tsx, and page.tsx files.

Now you will create the dashboard layout. This file wraps every page under the (dashboard) route group with a sidebar, tenant switcher, and navigation links.

  • Open the taskboard project in your code editor.
  • Create a new file at src/app/(dashboard)/layout.tsx and paste the following code:
'use client';

import { useState, useEffect } from 'react';
import Link from 'next/link';

// Define the two tenants with their Hasura IDs
const TENANTS = [
  { orgId: '[[ORG_ID_ACME="your Acme Corp organization UUID from Hasura"]]', userId: '[[USER_ID_ACME="your Acme Corp user UUID from Hasura"]]', name: 'Acme Corp', color: 'bg-tenant-acme' },
  { orgId: '[[ORG_ID_STARK="your Stark Industries organization UUID from Hasura"]]', userId: '[[USER_ID_STARK="your Stark Industries user UUID from Hasura"]]', name: 'Stark Industries', color: 'bg-tenant-stark' },
];

// Helper to store the current tenant in browser cookies
function setTenantCookies(orgId: string, userId: string) {
  document.cookie = `x-tenant-org-id=${orgId}; path=/`;
  document.cookie = `x-tenant-user-id=${userId}; path=/`;
}

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [currentTenant, setCurrentTenant] = useState(TENANTS[0]);

  // Set cookies whenever the tenant changes
  useEffect(() => {
    setTenantCookies(currentTenant.orgId, currentTenant.userId);
  }, [currentTenant]);

  function handleTenantSwitch(tenant: typeof TENANTS[0]) {
    setCurrentTenant(tenant);
    setTenantCookies(tenant.orgId, tenant.userId);
    window.location.reload();
  }

  return (
    <div className="flex h-screen">
      <aside className="w-sidebar border-r border-border bg-surface-alt p-4 flex flex-col gap-4">
        <div className="flex items-center gap-2">
          <div className={`w-3 h-3 rounded-full ${currentTenant.color}`} />
          <span className="font-semibold text-text">{currentTenant.name}</span>
        </div>

        <select
          className="border border-border rounded px-2 py-1 text-sm"
          value={currentTenant.orgId}
          onChange={(e) => {
            const tenant = TENANTS.find((t) => t.orgId === e.target.value);
            if (tenant) handleTenantSwitch(tenant);
          }}
        >
          {TENANTS.map((t) => (
            <option key={t.orgId} value={t.orgId}>
              {t.name}
            </option>
          ))}
        </select>

        <nav className="flex flex-col gap-2 mt-4">
          <Link href="/tasks" className="text-sm text-text hover:text-primary">
            Tasks
          </Link>
          <Link href="/settings" className="text-sm text-text-muted hover:text-primary">
            Settings
          </Link>
        </nav>
      </aside>

      <main className="flex-1 overflow-y-auto bg-surface p-6">
        {children}
      </main>
    </div>
  );
}

What does this code do?

  • TENANTS defines the two organizations you seeded in Step 2. The orgId and userId values must match the actual UUIDs Hasura generated for your seed data.
  • setTenantCookies stores the active tenant IDs in browser cookies. Later, your Apollo Client will read these cookies to attach the correct Hasura headers to every GraphQL request.
  • handleTenantSwitch updates state, writes cookies, then reloads the page so Apollo Client re-initializes with the new headers.
  • The <aside> renders a fixed sidebar with a colored dot, the org name, a dropdown to switch tenants, and navigation links.

Finding your UUIDs in Hasura

Open the Hasura Console at http://localhost:8080/console. Navigate to the Data tab, click organizations, and copy the id values for Acme Corp and Stark Industries. Do the same in the users table to get each user's UUID.

Next, create the placeholder tasks page that will later display the task board.

  • Create a new file at src/app/(dashboard)/tasks/page.tsx and paste the following:
export default function TasksPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-6">Task Board</h1>
      <p className="text-text-muted">Tasks will appear here once Apollo Client is connected.</p>
    </div>
  );
}

What does this code do?

This is a minimal placeholder. The /tasks URL will render this component inside the dashboard layout you just created. You will replace it with the full task board in Step 7.

Finally, create the placeholder login page for the auth route group.

  • Create a new file at src/app/(auth)/login/page.tsx and paste the following:
export default function LoginPage() {
  return (
    <div className="flex items-center justify-center h-screen bg-surface-alt">
      <div className="bg-surface border border-border rounded-lg p-8 max-w-sm w-full">
        <h1 className="text-xl font-bold text-text mb-4">Sign In</h1>
        <p className="text-sm text-text-muted mb-6">
          This is a placeholder login page. In the Secret Mission, you will
          replace this with real NextAuth.js authentication.
        </p>
        <a
          href="/tasks"
          className="block w-full bg-primary text-white text-center py-2 rounded hover:bg-primary-hover text-sm"
        >
          Continue to Dashboard (Mock Auth)
        </a>
      </div>
    </div>
  );
}

What does this code do?

This renders at /login (not /auth/login). The (auth) folder name is stripped from the URL because it is a route group.

The page links directly to /tasks for now. The Secret Mission will replace this with real NextAuth.js authentication.

  • Save all three files.

Seeing a red squiggly on the import?

If your editor shows a TypeScript error on the Link import in the dashboard layout, that is normal. TypeScript resolves imports after the next build. The error disappears once you run the dev server.

Why is my TypeScript import showing an error in the dashboard layout?

Configure design tokens and update the root layout

Design tokens are named values (colors, spacing, fonts) that you define once and reuse everywhere. Instead of scattering hex codes across your components, you reference token names like text-primary or bg-surface-alt. Tailwind CSS v4 lets you define these tokens directly in your CSS file using the @theme directive.

  • Open src/app/globals.css and replace its entire contents with the following:
@import 'tailwindcss';

@theme {
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-secondary: #64748b;
  --color-tenant-acme: #8b5cf6;
  --color-tenant-stark: #ef4444;
  --color-surface: #ffffff;
  --color-surface-alt: #f8fafc;
  --color-border: #e2e8f0;
  --color-text: #0f172a;
  --color-text-muted: #64748b;
  --font-sans: var(--font-inter), system-ui, sans-serif;
  --spacing-sidebar: 16rem;
}

What does this code do?

  • @import 'tailwindcss' loads all of Tailwind's utility classes.
  • @theme registers custom design tokens that Tailwind converts into utility classes. For example, --color-primary gives you text-primary, bg-primary, and border-primary automatically.
  • --color-tenant-acme and --color-tenant-stark give each organization its own brand color, used by the colored dot in the sidebar.
  • --spacing-sidebar sets a fixed sidebar width you can reference with the w-sidebar utility class.

Now update the root layout to load the Inter font and wrap the app with the Apollo provider placeholder.

  • Open src/app/layout.tsx and replace its entire contents with the following:
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ApolloWrapper } from '@/lib/apollo-wrapper';

const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });

export const metadata: Metadata = {
  title: 'Multi-Tenant Task Board',
  description: 'A multi-tenant task management app built with Next.js, Hasura, and Apollo',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${inter.variable} font-sans antialiased`}>
        <ApolloWrapper>{children}</ApolloWrapper>
      </body>
    </html>
  );
}

What does this code do?

  • Inter loads the Inter font from Google Fonts and assigns it to the --font-inter CSS variable. This connects to the --font-sans token you defined in globals.css.
  • ApolloWrapper will provide the Apollo Client context to every page. You will create this component in the next step.
  • metadata sets the page title and description for browser tabs and search engines.

Your editor will show an error on the ApolloWrapper import

The file src/lib/apollo-wrapper.tsx does not exist yet. TypeScript will flag this import as unresolved. This is expected. You will create this file in the next step when you set up Apollo Client.

Do not run npm run dev yet. The dev server will fail until Apollo is configured.

  • Save globals.css and layout.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in this step.

ⓧ I'd like to double check the full code

Here is each file you created or modified in this step. Compare against your own.

src/app/globals.css:

@import 'tailwindcss';

@theme {
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-secondary: #64748b;
  --color-tenant-acme: #8b5cf6;
  --color-tenant-stark: #ef4444;
  --color-surface: #ffffff;
  --color-surface-alt: #f8fafc;
  --color-border: #e2e8f0;
  --color-text: #0f172a;
  --color-text-muted: #64748b;
  --font-sans: var(--font-inter), system-ui, sans-serif;
  --spacing-sidebar: 16rem;
}

src/app/layout.tsx:

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ApolloWrapper } from '@/lib/apollo-wrapper';

const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });

export const metadata: Metadata = {
  title: 'Multi-Tenant Task Board',
  description: 'A multi-tenant task management app built with Next.js, Hasura, and Apollo',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${inter.variable} font-sans antialiased`}>
        <ApolloWrapper>{children}</ApolloWrapper>
      </body>
    </html>
  );
}

src/app/(dashboard)/layout.tsx:

'use client';

import { useState, useEffect } from 'react';
import Link from 'next/link';

const TENANTS = [
  { orgId: 'ORG_ID_ACME', userId: 'USER_ID_ACME', name: 'Acme Corp', color: 'bg-tenant-acme' },
  { orgId: 'ORG_ID_STARK', userId: 'USER_ID_STARK', name: 'Stark Industries', color: 'bg-tenant-stark' },
];

function setTenantCookies(orgId: string, userId: string) {
  document.cookie = `x-tenant-org-id=${orgId}; path=/`;
  document.cookie = `x-tenant-user-id=${userId}; path=/`;
}

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [currentTenant, setCurrentTenant] = useState(TENANTS[0]);

  useEffect(() => {
    setTenantCookies(currentTenant.orgId, currentTenant.userId);
  }, [currentTenant]);

  function handleTenantSwitch(tenant: typeof TENANTS[0]) {
    setCurrentTenant(tenant);
    setTenantCookies(tenant.orgId, tenant.userId);
    window.location.reload();
  }

  return (
    <div className="flex h-screen">
      <aside className="w-sidebar border-r border-border bg-surface-alt p-4 flex flex-col gap-4">
        <div className="flex items-center gap-2">
          <div className={`w-3 h-3 rounded-full ${currentTenant.color}`} />
          <span className="font-semibold text-text">{currentTenant.name}</span>
        </div>

        <select
          className="border border-border rounded px-2 py-1 text-sm"
          value={currentTenant.orgId}
          onChange={(e) => {
            const tenant = TENANTS.find((t) => t.orgId === e.target.value);
            if (tenant) handleTenantSwitch(tenant);
          }}
        >
          {TENANTS.map((t) => (
            <option key={t.orgId} value={t.orgId}>
              {t.name}
            </option>
          ))}
        </select>

        <nav className="flex flex-col gap-2 mt-4">
          <Link href="/tasks" className="text-sm text-text hover:text-primary">
            Tasks
          </Link>
          <Link href="/settings" className="text-sm text-text-muted hover:text-primary">
            Settings
          </Link>
        </nav>
      </aside>

      <main className="flex-1 overflow-y-auto bg-surface p-6">
        {children}
      </main>
    </div>
  );
}

src/app/(dashboard)/tasks/page.tsx:

export default function TasksPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-6">Task Board</h1>
      <p className="text-text-muted">Tasks will appear here once Apollo Client is connected.</p>
    </div>
  );
}

src/app/(auth)/login/page.tsx:

export default function LoginPage() {
  return (
    <div className="flex items-center justify-center h-screen bg-surface-alt">
      <div className="bg-surface border border-border rounded-lg p-8 max-w-sm w-full">
        <h1 className="text-xl font-bold text-text mb-4">Sign In</h1>
        <p className="text-sm text-text-muted mb-6">
          This is a placeholder login page. In the Secret Mission, you will
          replace this with real NextAuth.js authentication.
        </p>
        <a
          href="/tasks"
          className="block w-full bg-primary text-white text-center py-2 rounded hover:bg-primary-hover text-sm"
        >
          Continue to Dashboard (Mock Auth)
        </a>
      </div>
    </div>
  );
}

Your Next.js project structure is locked in. Every file has a clear purpose and every design token is defined in one place. Next up, you will connect this frontend to your Hasura GraphQL API by setting up Apollo Client for both server and client rendering.

Set Up Apollo Client for Next.js App Router

Your Next.js project has a folder structure, design tokens, and route groups. Now it needs a way to talk to your Hasura GraphQL endpoint.

Apollo Client is the industry-standard GraphQL client for React apps. In a Next.js App Router project, you need two separate Apollo setups: one for React Server Components (which run on the server and can use your admin secret safely) and one for Client Components (which run in the browser and send user-specific permission headers).

In this step, get ready to:
  • Install Apollo Client and its Next.js integration packages.
  • Create two Apollo client files: one for Server Components, one for Client Components.
  • Test the setup by fetching real data from Hasura on the tasks page.
Install Apollo Client and create the server-side client

Apollo's Next.js integration comes as a separate package called @apollo/client-integration-nextjs. It provides helpers that handle the unique requirements of the App Router, where code can run on the server or the browser depending on the component type.

  • Make sure you're inside the taskboard project folder in your terminal.
  • Install the Apollo Client packages by running:
npm install @apollo/client@latest @apollo/client-integration-nextjs graphql rxjs

Why four packages?

@apollo/client is the core GraphQL client. @apollo/client-integration-nextjs adds App Router support. graphql is a peer dependency that both packages need. rxjs is a required peer dependency for the Next.js integration.

  • You should see packages added to your package.json dependencies after the install completes.

Seeing peer dependency warnings?

Peer dependency warnings (not errors) are normal and won't break anything. If you see an actual error ("could not resolve dependency"), make sure your node_modules folder exists. Try deleting it and running npm install again.

Help me troubleshoot my npm install for Apollo Client

Next, you need an environment variable file so the server-side client can read your Hasura admin secret without hardcoding it.

  • Create a file called .env.local in the root of your taskboard folder (alongside package.json) with this content:
HASURA_ADMIN_SECRET=myadminsecret

What is .env.local?

Next.js automatically loads .env.local and makes its variables available via process.env in server-side code. This file is gitignored by default, keeping secrets out of version control.

Now create the server-side Apollo client that React Server Components will use.

  • Create a new folder called lib inside src/.
  • Create a file called apollo-rsc.ts inside src/lib/ with the following code:
import { HttpLink } from '@apollo/client';
import {
  registerApolloClient,
  ApolloClient,
  InMemoryCache,
} from '@apollo/client-integration-nextjs';

export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: 'http://localhost:8080/v1/graphql',
      headers: {
        'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET!,
      },
    }),
  });
});

What does this code do?

  • registerApolloClient creates an Apollo Client instance that's safe to use in React Server Components. It handles caching and deduplication across multiple server renders.
  • The HttpLink points to your Hasura endpoint and attaches the admin secret. Because this runs only on the server, the secret never reaches the browser.
  • The exported getClient function gives any Server Component full admin access to Hasura's data.
  • Save src/lib/apollo-rsc.ts.
Create the browser-side Apollo wrapper

The RSC client handles server-side fetches. But interactive features like mutations and refetching run in the browser inside Client Components. Those need their own Apollo Client that sends the current tenant's permission headers.

  • Create a file called apollo-wrapper.tsx inside src/lib/ with the following code:
'use client';

import { HttpLink } from '@apollo/client';
import { ApolloClient, InMemoryCache } from '@apollo/client-integration-nextjs';
import { ApolloNextAppProvider } from '@apollo/client-integration-nextjs';

// Helper to read a browser cookie by name
function getCookie(name: string): string | undefined {
  if (typeof document === 'undefined') return undefined;
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop()?.split(';').shift();
  return undefined;
}

// Creates a fresh Apollo Client for each browser session
function makeClient() {
  const httpLink = new HttpLink({
    uri: 'http://localhost:8080/v1/graphql',
    fetchOptions: { cache: 'no-store' },
    headers: {
      'x-hasura-role': 'user',
      'x-hasura-org-id': getCookie('x-tenant-org-id') || '',
      'x-hasura-user-id': getCookie('x-tenant-user-id') || '',
    },
  });

  return new ApolloClient({
    cache: new InMemoryCache(),
    link: httpLink,
  });
}

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}

What does this code do?

  • 'use client' marks this as a Client Component. It runs in the browser, not the server.
  • getCookie reads the tenant cookies that the dashboard layout sets when the user switches organizations.
  • makeClient builds an Apollo Client that attaches the current tenant's org ID and user ID as Hasura session headers. This is how Hasura knows which row-level permissions to apply.
  • ApolloNextAppProvider makes the client available to all Client Components via React context.
  • Save src/lib/apollo-wrapper.tsx.

Your root layout at src/app/layout.tsx already imports and wraps children with ApolloWrapper. Now that the file exists, that import will resolve correctly.

Why two separate Apollo Clients?

The RSC client runs on the server and uses the admin secret for unrestricted access. The wrapper client runs in the browser and sends user-role headers so Hasura applies row-level permissions.

Keeping them separate means secrets never leak to the browser, and browser queries are always scoped to the current tenant.

Seeing a TypeScript error about @/lib/apollo-wrapper?

If your editor shows an import error for @/lib/apollo-wrapper in layout.tsx, check that you created the file at src/lib/apollo-wrapper.tsx (not lib/apollo-wrapper.tsx outside the src folder). The @/ alias maps to src/.

Help me fix my Apollo wrapper import error in Next.js

Test the Apollo setup with a server-side query

Both clients are created. Time to prove they work by fetching real data from Hasura. You will write a quick test query in the tasks page using the RSC client.

  • Open src/app/(dashboard)/tasks/page.tsx and replace its contents with the following code:
import { gql } from '@apollo/client';
import { getClient } from '@/lib/apollo-rsc';

// A simple test query to confirm Apollo connects to Hasura
const GET_ORGANIZATIONS = gql`
  query GetOrganizations {
    organizations {
      id
      name
      slug
    }
  }
`;

export default async function TasksPage() {
  // Use the RSC client to fetch data on the server
  const { data } = await getClient().query({ query: GET_ORGANIZATIONS });

  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-4">Apollo Test</h1>
      <p className="text-text-muted mb-4">Organizations from Hasura:</p>
      <ul className="list-disc pl-6">
        {data.organizations.map((org: { id: string; name: string; slug: string }) => (
          <li key={org.id} className="text-text">
            {org.name} ({org.slug})
          </li>
        ))}
      </ul>
    </div>
  );
}

What does this code do?

  • This page is an async function. That makes it a React Server Component. It runs entirely on the server during page load.
  • getClient() returns the RSC Apollo Client from apollo-rsc.ts. It uses the admin secret, so it can read all organizations regardless of tenant.
  • The query result is rendered as plain HTML before being sent to the browser. No JavaScript bundle is needed for this fetch.
  • Save src/app/(dashboard)/tasks/page.tsx.
  • Make sure your Docker containers are still running. Confirm by running:
docker compose ps
  • You should see both postgres and graphql-engine with a status of "Up".
  • Start the Next.js dev server by running:
npm run dev
  • Open http://localhost:3000/tasks in your browser.
  • You should see an "Apollo Test" heading with a list showing both organizations: Acme Corp and Stark Industries.

Seeing a server error instead of organizations?

Check that your .env.local file is in the root of the taskboard folder (the same level as package.json). Next.js only loads environment variables from that location.

If you created .env.local while the dev server was already running, stop it with Ctrl+C and restart with npm run dev. Environment variables are only read at startup.

Also confirm your Docker containers are running. The RSC client needs Hasura to be reachable at http://localhost:8080.

Help me debug my Apollo Server Component query to Hasura

RSC client vs wrapper client: when does each one run?

The RSC client (apollo-rsc.ts) runs on the server during page load. It uses the admin secret, so it bypasses all permission rules. Use it for initial page data that isn't user-specific.

The wrapper client (apollo-wrapper.tsx) runs in the browser. It reads tenant cookies and sends them as Hasura headers. All row-level permissions apply. Use it for interactive queries and mutations.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: apollo-rsc.ts, apollo-wrapper.tsx, and .env.local.

ⓧ I'd like to double check the full code

Here is the complete code for each file created in this step.

HASURA_ADMIN_SECRET=myadminsecret
import { HttpLink } from '@apollo/client';
import {
  registerApolloClient,
  ApolloClient,
  InMemoryCache,
} from '@apollo/client-integration-nextjs';

export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: 'http://localhost:8080/v1/graphql',
      headers: {
        'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET!,
      },
    }),
  });
});
'use client';

import { HttpLink } from '@apollo/client';
import { ApolloClient, InMemoryCache } from '@apollo/client-integration-nextjs';
import { ApolloNextAppProvider } from '@apollo/client-integration-nextjs';

function getCookie(name: string): string | undefined {
  if (typeof document === 'undefined') return undefined;
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop()?.split(';').shift();
  return undefined;
}

function makeClient() {
  const httpLink = new HttpLink({
    uri: 'http://localhost:8080/v1/graphql',
    fetchOptions: { cache: 'no-store' },
    headers: {
      'x-hasura-role': 'user',
      'x-hasura-org-id': getCookie('x-tenant-org-id') || '',
      'x-hasura-user-id': getCookie('x-tenant-user-id') || '',
    },
  });

  return new ApolloClient({
    cache: new InMemoryCache(),
    link: httpLink,
  });
}

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ApolloWrapper } from '@/lib/apollo-wrapper';

const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });

export const metadata: Metadata = {
  title: 'Multi-Tenant Task Board',
  description: 'A multi-tenant task management app built with Next.js, Hasura, and Apollo',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${inter.variable} font-sans antialiased`}>
        <ApolloWrapper>{children}</ApolloWrapper>
      </body>
    </html>
  );
}

Apollo Client is connected to Hasura from both the server and the browser. Next up, you will set up GraphQL Codegen to generate type-safe hooks from your schema. No more writing queries by hand with no autocomplete.

Configure GraphQL Codegen for Type-Safe Operations

Your Apollo Client is connected to Hasura and you can query data. The problem is that right now your queries are raw strings with no type checking.

GraphQL Code Generation solves this by reading your Hasura schema and your .graphql operation files, then auto-generating TypeScript types and ready-to-use React hooks. A typo in a field name becomes a compile-time error instead of a runtime mystery.

In this step, get ready to:
  • Install GraphQL Codegen and configure it to read your Hasura schema.
  • Write GraphQL operations and generate typed React hooks.
  • Use the generated hooks in your tasks page for type-safe data fetching.
Install Codegen packages and create the configuration file

GraphQL Codegen needs four packages: the CLI runner, a TypeScript type generator, an operations type generator, and a React Apollo hook generator.

  • Install the Codegen dev dependencies by running this command in your terminal (make sure you're in the taskboard folder):
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 whole code generation process.
  • @graphql-codegen/typescript generates base TypeScript types from your GraphQL schema (tables, enums, scalars).
  • @graphql-codegen/typescript-operations generates types for your specific queries and mutations (input variables, return shapes).
  • @graphql-codegen/typescript-react-apollo generates ready-to-use React hooks like useGetTasksQuery that wrap Apollo's useQuery with full typing.

Install failed or peer dependency warnings?

Make sure you're inside the taskboard folder, not the parent directory. If you see peer dependency warnings (not errors), those are safe to ignore for dev dependencies.

Help me fix my Codegen package installation

Codegen needs a configuration file that tells it where your schema lives, where your operations are, and what to generate.

  • Create a file called codegen.ts in the root of your taskboard folder (at the same level as package.json).
  • Paste the following configuration into codegen.ts:
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  overwrite: true,
  schema: [
    {
      'http://localhost:8080/v1/graphql': {
        headers: {
          'x-hasura-admin-secret': 'myadminsecret',
        },
      },
    },
  ],
  documents: ['src/**/*.graphql'],
  ignoreNoDocuments: true,
  generates: {
    'src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-apollo',
      ],
      config: {
        withHooks: true,
        withHOC: false,
        withComponent: false,
        skipTypename: false,
        scalars: {
          uuid: 'string',
          timestamptz: 'string',
        },
      },
    },
  },
};

export default config;

What does this configuration do?

  • schema points to your running Hasura instance with the admin secret so Codegen can introspect all tables and types.
  • documents tells Codegen to scan all .graphql files in your src folder for queries and mutations to generate hooks for.
  • ignoreNoDocuments: true prevents Codegen from erroring when no .graphql files exist yet.
  • withHooks: true generates React hooks (like useGetTasksQuery) instead of raw document nodes.
  • scalars maps Hasura's custom types (uuid, timestamptz) to TypeScript string so your types stay simple.
  • Save codegen.ts.

Why not use @graphql-codegen/client-preset?

Apollo's official documentation explicitly recommends against using client-preset for Apollo Client apps. It adds runtime code that conflicts with Apollo's own caching layer. The three separate plugins (typescript, typescript-operations, typescript-react-apollo) generate clean types with zero runtime overhead.

Write your GraphQL operations and run Codegen

Codegen reads .graphql files to know which queries and mutations you actually use. You write the operations once, and Codegen generates a typed hook for each one.

  • Create a new folder at src/graphql/ by running:
mkdir -p src/graphql
  • Create a file called tasks.graphql inside src/graphql/ and paste the following operations:
query GetTasks {
  tasks(order_by: { created_at: desc }) {
    id
    title
    description
    status
    created_at
    user {
      name
    }
  }
}

mutation CreateTask($title: String!, $description: String = "") {
  insert_tasks_one(object: { title: $title, description: $description }) {
    id
    title
    description
    status
    created_at
  }
}

mutation UpdateTaskStatus($id: uuid!, $status: String!) {
  update_tasks_by_pk(pk_columns: { id: $id }, _set: { status: $status }) {
    id
    status
  }
}

What do these operations do?

  • GetTasks fetches all tasks ordered by newest first, including the nested user's name (via the relationship you set up in Hasura).
  • CreateTask inserts a new task. Notice it only needs title and description. Hasura's permission presets automatically fill in organization_id and created_by from your headers.
  • UpdateTaskStatus changes a task's status (like moving from "todo" to "in_progress").
  • Save src/graphql/tasks.graphql.
  • Make sure your Docker containers are still running (Hasura needs to be accessible for schema introspection).
  • Run Codegen to generate your typed hooks:
npx graphql-codegen
  • You should see output confirming that src/generated/graphql.ts was generated successfully.
  • Open src/generated/graphql.ts in your editor and scroll through it.

You should see TypeScript types for every table in your Hasura schema, plus three generated hooks: useGetTasksQuery, useCreateTaskMutation, and useUpdateTaskStatusMutation.

Seeing a connection error or empty output?

Codegen needs to reach your Hasura instance to introspect the schema. Check that your Docker containers are running with docker compose ps. Both postgres and graphql-engine should show as "running".

If you see "Failed to fetch introspection", confirm Hasura Console is still accessible at http://localhost:8080/console. If it's not loading, restart Docker with docker compose up -d.

Help me troubleshoot my GraphQL Codegen errors

Add Codegen scripts and update the tasks page to use generated hooks

Running npx graphql-codegen every time you change a .graphql file gets tedious. Adding npm scripts makes regeneration a single command, and watch mode regenerates automatically on save.

  • Open package.json in your editor.
  • Find the "scripts" section.
  • Add these two scripts alongside the existing ones:
"codegen": "graphql-codegen",
"codegen:watch": "graphql-codegen --watch"

What do these scripts do?

  • npm run codegen runs a one-off generation. Use this after adding new operations.
  • npm run codegen:watch watches your .graphql files and regenerates automatically when they change. Run this in a separate terminal during development.
  • Save package.json.

Now let's prove that the generated hooks actually work by updating your tasks page to use them.

  • Open src/app/(dashboard)/tasks/page.tsx.
  • Replace the entire file contents with the following code:
'use client';

import { useGetTasksQuery } from '@/generated/graphql';

// Status column definitions for grouping tasks
const STATUS_COLUMNS = ['todo', 'in_progress', 'done'] as const;

const STATUS_LABELS: Record<string, string> = {
  todo: 'To Do',
  in_progress: 'In Progress',
  done: 'Done',
};

export default function TasksPage() {
  // useGetTasksQuery is generated by Codegen - fully typed!
  const { data, loading, error } = useGetTasksQuery();

  if (loading) return <p className="text-text-muted">Loading tasks...</p>;
  if (error) return <p className="text-red-500">Error: {error.message}</p>;

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

  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-6">Task Board</h1>

      <div className="grid grid-cols-3 gap-4">
        {STATUS_COLUMNS.map((status) => (
          <div key={status} className="bg-surface-alt rounded-lg p-4">
            <h2 className="font-semibold text-text mb-3">{STATUS_LABELS[status]}</h2>
            <div className="flex flex-col gap-2">
              {tasks
                .filter((task) => task.status === status)
                .map((task) => (
                  <div
                    key={task.id}
                    className="bg-surface border border-border rounded p-3"
                  >
                    <p className="text-sm font-medium text-text">{task.title}</p>
                    {task.description && (
                      <p className="text-xs text-text-muted mt-1">{task.description}</p>
                    )}
                    <span className="text-xs text-text-muted mt-2 block">
                      {task.user?.name}
                    </span>
                  </div>
                ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

What changed here?

  • 'use client' marks this as a Client Component. React hooks (like useGetTasksQuery) only work in Client Components.
  • The import pulls useGetTasksQuery from the generated file. This hook is fully typed. If you hover over task.title in your editor, TypeScript knows it's a string.
  • The page groups tasks into three status columns (To Do, In Progress, Done) using the same status values stored in your database.
  • Save src/app/(dashboard)/tasks/page.tsx.
  • Start the Next.js dev server by running:
npm run dev
  • Open http://localhost:3000/tasks in your browser.

You should see three columns (To Do, In Progress, Done) with your seed tasks displayed in cards. The data comes through the generated hook, which applies the tenant headers from your cookies.

TypeScript autocomplete in action

Try typing task. inside the map function. Your editor should autocomplete with every field from the GetTasks query: id, title, description, status, created_at, user. If you type a field that doesn't exist, TypeScript shows a red squiggle immediately.

Page not loading or showing errors?

If you see a module not found error for @/generated/graphql, make sure you ran npx graphql-codegen successfully and that src/generated/graphql.ts exists.

If you see an empty page with no tasks, make sure the tenant cookies are set. Visit http://localhost:3000/tasks through the dashboard (from the sidebar link) so the dashboard layout sets the cookies first.

Help me debug my tasks page with generated hooks

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here's the complete codegen.ts file:

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  overwrite: true,
  schema: [
    {
      'http://localhost:8080/v1/graphql': {
        headers: {
          'x-hasura-admin-secret': 'myadminsecret',
        },
      },
    },
  ],
  documents: ['src/**/*.graphql'],
  ignoreNoDocuments: true,
  generates: {
    'src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-apollo',
      ],
      config: {
        withHooks: true,
        withHOC: false,
        withComponent: false,
        skipTypename: false,
        scalars: {
          uuid: 'string',
          timestamptz: 'string',
        },
      },
    },
  },
};

export default config;

Here's the complete src/graphql/tasks.graphql file:

query GetTasks {
  tasks(order_by: { created_at: desc }) {
    id
    title
    description
    status
    created_at
    user {
      name
    }
  }
}

mutation CreateTask($title: String!, $description: String = "") {
  insert_tasks_one(object: { title: $title, description: $description }) {
    id
    title
    description
    status
    created_at
  }
}

mutation UpdateTaskStatus($id: uuid!, $status: String!) {
  update_tasks_by_pk(pk_columns: { id: $id }, _set: { status: $status }) {
    id
    status
  }
}

Here's the updated src/app/(dashboard)/tasks/page.tsx:

'use client';

import { useGetTasksQuery } from '@/generated/graphql';

const STATUS_COLUMNS = ['todo', 'in_progress', 'done'] as const;

const STATUS_LABELS: Record<string, string> = {
  todo: 'To Do',
  in_progress: 'In Progress',
  done: 'Done',
};

export default function TasksPage() {
  const { data, loading, error } = useGetTasksQuery();

  if (loading) return <p className="text-text-muted">Loading tasks...</p>;
  if (error) return <p className="text-red-500">Error: {error.message}</p>;

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

  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-6">Task Board</h1>

      <div className="grid grid-cols-3 gap-4">
        {STATUS_COLUMNS.map((status) => (
          <div key={status} className="bg-surface-alt rounded-lg p-4">
            <h2 className="font-semibold text-text mb-3">{STATUS_LABELS[status]}</h2>
            <div className="flex flex-col gap-2">
              {tasks
                .filter((task) => task.status === status)
                .map((task) => (
                  <div
                    key={task.id}
                    className="bg-surface border border-border rounded p-3"
                  >
                    <p className="text-sm font-medium text-text">{task.title}</p>
                    {task.description && (
                      <p className="text-xs text-text-muted mt-1">{task.description}</p>
                    )}
                    <span className="text-xs text-text-muted mt-2 block">
                      {task.user?.name}
                    </span>
                  </div>
                ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Your GraphQL workflow is now fully typed from end to end. Any time you add a new query or mutation to a .graphql file, run npm run codegen and you get a new hook with full TypeScript safety. Next up, you'll build the complete task board UI with create, update, and tenant-switching functionality.

Build the Task Board UI with Tenant Switching

Your backend is fully configured with permissions that isolate each organization's data. Now it is time to bring that isolation to life in the browser with a real UI.

In this step, you will connect the dashboard layout to real database IDs, build out the task board with columns and forms, and prove that tenant switching delivers completely different data per organization.

In this step, get ready to:
  • Plug real organization and user UUIDs into the tenant switcher.
  • Build the task board UI with Kanban columns, a create form, and status updates.
  • Verify that switching tenants shows completely different task data.
Plug real organization IDs into the dashboard layout

The dashboard layout already has a tenant switcher, but the TENANTS array uses placeholder strings like ORG_ID_ACME. For the Apollo client to send valid headers to Hasura, those placeholders need to contain the actual UUIDs from your database.

  • Open the Hasura Console at http://localhost:8080/console.
  • Click the Data tab in the top navigation.
  • Click on the organizations table, then click Browse Rows.
  • Copy the id value for Acme Corp and the id value for Stark Industries. Keep these somewhere handy.
  • Click on the users table and copy the id value for each user (one per organization).

You should now have four UUIDs: one org ID and one user ID for each tenant.

  • Open src/app/(dashboard)/layout.tsx in your editor.
  • Find the TENANTS array at the top of the file:
const TENANTS = [
  { orgId: 'ORG_ID_ACME', userId: 'USER_ID_ACME', name: 'Acme Corp', color: 'bg-tenant-acme' },
  { orgId: 'ORG_ID_STARK', userId: 'USER_ID_STARK', name: 'Stark Industries', color: 'bg-tenant-stark' },
];
  • Replace the four placeholder strings with your real UUIDs so the array looks like this:
const TENANTS = [
  { orgId: '[[ORG_ID_ACME="your Acme Corp organization UUID"]]', userId: '[[USER_ID_ACME="your Acme Corp user UUID"]]', name: 'Acme Corp', color: 'bg-tenant-acme' },
  { orgId: '[[ORG_ID_STARK="your Stark Industries organization UUID"]]', userId: '[[USER_ID_STARK="your Stark Industries user UUID"]]', name: 'Stark Industries', color: 'bg-tenant-stark' },
];

What does this change do?

When the learner selects a tenant from the dropdown, setTenantCookies stores these UUIDs in browser cookies. The Apollo client reads those cookies and attaches them as X-Hasura-Org-Id and X-Hasura-User-Id headers on every GraphQL request.

Hasura uses those headers to evaluate the row-level permissions you configured earlier. This is the glue that connects your frontend dropdown to backend data isolation.

  • Save src/app/(dashboard)/layout.tsx.

Not sure which UUID belongs to which organization?

In Hasura Console, the organizations table shows both the id and name columns side by side. Match the UUID in the id column with the name in the same row. For users, check the organization_id column to confirm which org each user belongs to.

Help me find my organization and user UUIDs in Hasura Console.

Build the full task board page

The tasks page currently fetches and displays tasks, but it lacks a form to create new tasks and the ability to update task statuses. You will replace the file contents with a full Kanban-style board that organizes tasks into three columns.

  • Open src/app/(dashboard)/tasks/page.tsx and replace the entire file contents with the following code:
'use client';

import { useState } from 'react';
import { useGetTasksQuery, useCreateTaskMutation, useUpdateTaskStatusMutation } from '@/generated/graphql';

// Define the three status columns for the Kanban board
const STATUS_COLUMNS = ['todo', 'in_progress', 'done'] as const;

// Human-readable labels for each status
const STATUS_LABELS: Record<string, string> = {
  todo: 'To Do',
  in_progress: 'In Progress',
  done: 'Done',
};

export default function TasksPage() {
  // Fetch tasks and set up mutation hooks from Codegen
  const { data, loading, error, refetch } = useGetTasksQuery();
  const [createTask] = useCreateTaskMutation();
  const [updateStatus] = useUpdateTaskStatusMutation();
  const [newTitle, setNewTitle] = useState('');

  // Handle new task creation via direct client mutation
  async function handleCreateTask(e: React.FormEvent) {
    e.preventDefault();
    if (!newTitle.trim()) return;
    await createTask({
      variables: { title: newTitle, description: '' },
    });
    setNewTitle('');
    refetch();
  }

  // Handle status change via direct client mutation
  async function handleStatusChange(taskId: string, newStatus: string) {
    await updateStatus({
      variables: { id: taskId, status: newStatus },
    });
    refetch();
  }

  if (loading) return <p className="text-text-muted">Loading tasks...</p>;
  if (error) return <p className="text-red-500">Error: {error.message}</p>;

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

  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-6">Task Board</h1>

      <form onSubmit={handleCreateTask} className="flex gap-2 mb-8">
        <input
          type="text"
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          placeholder="New task title..."
          className="flex-1 border border-border rounded px-3 py-2 text-sm"
        />
        <button
          type="submit"
          className="bg-primary text-white px-4 py-2 rounded text-sm hover:bg-primary-hover"
        >
          Add Task
        </button>
      </form>

      <div className="grid grid-cols-3 gap-4">
        {STATUS_COLUMNS.map((status) => (
          <div key={status} className="bg-surface-alt rounded-lg p-4">
            <h2 className="font-semibold text-text mb-3">{STATUS_LABELS[status]}</h2>
            <div className="flex flex-col gap-2">
              {tasks
                .filter((task) => task.status === status)
                .map((task) => (
                  <div
                    key={task.id}
                    className="bg-surface border border-border rounded p-3"
                  >
                    <p className="text-sm font-medium text-text">{task.title}</p>
                    {task.description && (
                      <p className="text-xs text-text-muted mt-1">{task.description}</p>
                    )}
                    <div className="flex items-center justify-between mt-2">
                      <span className="text-xs text-text-muted">
                        {task.user?.name}
                      </span>
                      <select
                        value={task.status}
                        onChange={(e) => handleStatusChange(task.id, e.target.value)}
                        className="text-xs border border-border rounded px-1 py-0.5"
                      >
                        {STATUS_COLUMNS.map((s) => (
                          <option key={s} value={s}>
                            {STATUS_LABELS[s]}
                          </option>
                        ))}
                      </select>
                    </div>
                  </div>
                ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

What does this code do?

  • STATUS_COLUMNS and STATUS_LABELS define the three board columns and their display names.
  • useGetTasksQuery fetches all tasks scoped to the current tenant (via the headers Apollo sends).
  • useCreateTaskMutation fires a direct client-side mutation. Hasura automatically sets organization_id and created_by from the column presets you configured in the permissions step.
  • useUpdateTaskStatusMutation moves tasks between columns. The row-level permission ensures you can only update tasks in your own organization.
  • refetch() after each mutation re-runs the query so the UI reflects the latest state.
  • Save src/app/(dashboard)/tasks/page.tsx.
  • In your terminal, make sure the dev server is running by running:
npm run dev
  • Open http://localhost:3000/tasks in your browser. You should see a "Task Board" heading, a text input with an Add Task button, and three columns labeled To Do, In Progress, and Done.

Seeing a blank page or error?

If you see "Error: ..." in red, check that your Docker containers are still running with docker compose ps. The Hasura endpoint must be reachable at http://localhost:8080.

If you see a TypeScript error about missing imports, confirm that src/generated/graphql.ts exists. If not, re-run npx graphql-codegen to regenerate it.

My task board page is showing an error instead of tasks.

Add a loading skeleton and verify tenant switching

Next.js App Router uses a special loading.tsx file to show a placeholder while the page data loads. This gives users instant visual feedback instead of a blank screen.

  • Create a new file at src/app/(dashboard)/tasks/loading.tsx and add the following code:
export default function TasksLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 w-48 bg-surface-alt rounded mb-6" />
      <div className="grid grid-cols-3 gap-4">
        {[1, 2, 3].map((col) => (
          <div key={col} className="bg-surface-alt rounded-lg p-4 h-64" />
        ))}
      </div>
    </div>
  );
}

What does this code do?

The animate-pulse class from Tailwind CSS creates a gentle pulsing animation on placeholder rectangles that mimic the layout of the real content. Next.js automatically renders this component while the tasks page is loading.

  • Save src/app/(dashboard)/tasks/loading.tsx.

Now for the real test. You will switch tenants and confirm that the board shows completely different data for each organization.

  • Go to http://localhost:3000/tasks in your browser.
  • In the sidebar, confirm the tenant dropdown shows Acme Corp selected. You should see only the tasks you seeded for Acme Corp.
  • Use the dropdown to switch to Stark Industries. The page reloads and the task board now displays only Stark Industries tasks.
  • Type a task title into the input field and click Add Task. The new task appears in the To Do column for the currently selected tenant only.
  • Switch back to Acme Corp and confirm the task you just created for Stark Industries is NOT visible.

Why does switching tenants show different data?

The same GetTasks query runs both times. The only difference is the X-Hasura-Org-Id header that Apollo sends with the request.

Hasura evaluates the row-level permission filter {"organization_id": {"_eq": "X-Hasura-Org-Id"}} against every row. Rows that do not match the header value are excluded from the response. The frontend never sees data it should not have access to.

Both tenants show the same tasks?

Check that the UUIDs in your TENANTS array match the actual organization IDs in your database. If they are still the placeholder strings like ORG_ID_ACME, Hasura will not match any rows and both tenants will show empty results or all results.

Also confirm your browser actually reloaded after switching. The handleTenantSwitch function calls window.location.reload() so that the Apollo client re-reads the updated cookies.

Help me debug why my tenant switching is not filtering tasks correctly.

✔️ 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

Here is the full code for each file you modified or created in this step.

'use client';

import { useState, useEffect } from 'react';
import Link from 'next/link';

const TENANTS = [
  { orgId: 'ORG_ID_ACME', userId: 'USER_ID_ACME', name: 'Acme Corp', color: 'bg-tenant-acme' },
  { orgId: 'ORG_ID_STARK', userId: 'USER_ID_STARK', name: 'Stark Industries', color: 'bg-tenant-stark' },
];

function setTenantCookies(orgId: string, userId: string) {
  document.cookie = `x-tenant-org-id=${orgId}; path=/`;
  document.cookie = `x-tenant-user-id=${userId}; path=/`;
}

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [currentTenant, setCurrentTenant] = useState(TENANTS[0]);

  useEffect(() => {
    setTenantCookies(currentTenant.orgId, currentTenant.userId);
  }, [currentTenant]);

  function handleTenantSwitch(tenant: typeof TENANTS[0]) {
    setCurrentTenant(tenant);
    setTenantCookies(tenant.orgId, tenant.userId);
    window.location.reload();
  }

  return (
    <div className="flex h-screen">
      <aside className="w-sidebar border-r border-border bg-surface-alt p-4 flex flex-col gap-4">
        <div className="flex items-center gap-2">
          <div className={`w-3 h-3 rounded-full ${currentTenant.color}`} />
          <span className="font-semibold text-text">{currentTenant.name}</span>
        </div>

        <select
          className="border border-border rounded px-2 py-1 text-sm"
          value={currentTenant.orgId}
          onChange={(e) => {
            const tenant = TENANTS.find((t) => t.orgId === e.target.value);
            if (tenant) handleTenantSwitch(tenant);
          }}
        >
          {TENANTS.map((t) => (
            <option key={t.orgId} value={t.orgId}>
              {t.name}
            </option>
          ))}
        </select>

        <nav className="flex flex-col gap-2 mt-4">
          <Link href="/tasks" className="text-sm text-text hover:text-primary">
            Tasks
          </Link>
          <Link href="/settings" className="text-sm text-text-muted hover:text-primary">
            Settings
          </Link>
        </nav>
      </aside>

      <main className="flex-1 overflow-y-auto bg-surface p-6">
        {children}
      </main>
    </div>
  );
}
'use client';

import { useState } from 'react';
import { useGetTasksQuery, useCreateTaskMutation, useUpdateTaskStatusMutation } from '@/generated/graphql';

const STATUS_COLUMNS = ['todo', 'in_progress', 'done'] as const;

const STATUS_LABELS: Record<string, string> = {
  todo: 'To Do',
  in_progress: 'In Progress',
  done: 'Done',
};

export default function TasksPage() {
  const { data, loading, error, refetch } = useGetTasksQuery();
  const [createTask] = useCreateTaskMutation();
  const [updateStatus] = useUpdateTaskStatusMutation();
  const [newTitle, setNewTitle] = useState('');

  async function handleCreateTask(e: React.FormEvent) {
    e.preventDefault();
    if (!newTitle.trim()) return;
    await createTask({
      variables: { title: newTitle, description: '' },
    });
    setNewTitle('');
    refetch();
  }

  async function handleStatusChange(taskId: string, newStatus: string) {
    await updateStatus({
      variables: { id: taskId, status: newStatus },
    });
    refetch();
  }

  if (loading) return <p className="text-text-muted">Loading tasks...</p>;
  if (error) return <p className="text-red-500">Error: {error.message}</p>;

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

  return (
    <div>
      <h1 className="text-2xl font-bold text-text mb-6">Task Board</h1>

      <form onSubmit={handleCreateTask} className="flex gap-2 mb-8">
        <input
          type="text"
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          placeholder="New task title..."
          className="flex-1 border border-border rounded px-3 py-2 text-sm"
        />
        <button
          type="submit"
          className="bg-primary text-white px-4 py-2 rounded text-sm hover:bg-primary-hover"
        >
          Add Task
        </button>
      </form>

      <div className="grid grid-cols-3 gap-4">
        {STATUS_COLUMNS.map((status) => (
          <div key={status} className="bg-surface-alt rounded-lg p-4">
            <h2 className="font-semibold text-text mb-3">{STATUS_LABELS[status]}</h2>
            <div className="flex flex-col gap-2">
              {tasks
                .filter((task) => task.status === status)
                .map((task) => (
                  <div
                    key={task.id}
                    className="bg-surface border border-border rounded p-3"
                  >
                    <p className="text-sm font-medium text-text">{task.title}</p>
                    {task.description && (
                      <p className="text-xs text-text-muted mt-1">{task.description}</p>
                    )}
                    <div className="flex items-center justify-between mt-2">
                      <span className="text-xs text-text-muted">
                        {task.user?.name}
                      </span>
                      <select
                        value={task.status}
                        onChange={(e) => handleStatusChange(task.id, e.target.value)}
                        className="text-xs border border-border rounded px-1 py-0.5"
                      >
                        {STATUS_COLUMNS.map((s) => (
                          <option key={s} value={s}>
                            {STATUS_LABELS[s]}
                          </option>
                        ))}
                      </select>
                    </div>
                  </div>
                ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
export default function TasksLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 w-48 bg-surface-alt rounded mb-6" />
      <div className="grid grid-cols-3 gap-4">
        {[1, 2, 3].map((col) => (
          <div key={col} className="bg-surface-alt rounded-lg p-4 h-64" />
        ))}
      </div>
    </div>
  );
}

Your task board is fully functional with real multi-tenancy in action. Next up, you will explore when to use a direct client mutation versus a server-side API route for operations that need elevated access or cross-table validation.

Add an API Route for Server-Side Business Logic

Your task board is fully functional. Tenants switch cleanly, and Hasura permissions enforce complete data isolation at the database level.

Not every operation belongs on the client, though. Some operations need server-side validation that spans multiple tables, or they require secrets (like the admin secret) that must never reach the browser. In this step, you will build a Next.js API route that demonstrates when and why to move logic to the server.

In this step, get ready to:
  • Create an API route that validates cross-table business logic before assigning a task.
  • Wire up an Assign button in the task card that calls your new API route.
  • Document a decision framework for when to use API routes vs direct mutations.
Create the task assignment API route

Assigning a task to a user requires checking that both the task and the user belong to the same organization. This is a business rule that spans two tables. The client cannot perform this check securely because it would need the admin secret to query both tables without permission filters.

  • Create the folder path for your API route by running this command from your taskboard folder:
mkdir -p src/app/api/tasks/assign
  • Create a new file at src/app/api/tasks/assign/route.ts and add the following code:
import { NextRequest, NextResponse } from 'next/server';

const HASURA_ENDPOINT = 'http://localhost:8080/v1/graphql';
const HASURA_ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET || 'myadminsecret';

export async function POST(request: NextRequest) {
  const { taskId, userId } = await request.json();

  // Validate that both required fields are present
  if (!taskId || !userId) {
    return NextResponse.json(
      { error: 'taskId and userId are required' },
      { status: 400 }
    );
  }

  // Query both the task and user to check their organizations match
  const validationQuery = `
    query ValidateAssignment($taskId: uuid!, $userId: uuid!) {
      tasks_by_pk(id: $taskId) {
        id
        organization_id
      }
      users_by_pk(id: $userId) {
        id
        organization_id
      }
    }
  `;

  const validationRes = await fetch(HASURA_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-hasura-admin-secret': HASURA_ADMIN_SECRET,
    },
    body: JSON.stringify({
      query: validationQuery,
      variables: { taskId, userId },
    }),
  });

  const validationData = await validationRes.json();
  const task = validationData.data?.tasks_by_pk;
  const user = validationData.data?.users_by_pk;

  // Return 404 if either record doesn't exist
  if (!task || !user) {
    return NextResponse.json(
      { error: 'Task or user not found' },
      { status: 404 }
    );
  }

  // Enforce same-org rule: users can only be assigned tasks within their org
  if (task.organization_id !== user.organization_id) {
    return NextResponse.json(
      { error: 'Cannot assign task to user from a different organization' },
      { status: 403 }
    );
  }

  // Perform the assignment using admin privileges
  const assignMutation = `
    mutation AssignTask($taskId: uuid!, $userId: uuid!) {
      update_tasks_by_pk(pk_columns: { id: $taskId }, _set: { assigned_to: $userId }) {
        id
        assigned_to
      }
    }
  `;

  const assignRes = await fetch(HASURA_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-hasura-admin-secret': HASURA_ADMIN_SECRET,
    },
    body: JSON.stringify({
      query: assignMutation,
      variables: { taskId, userId },
    }),
  });

  const assignData = await assignRes.json();

  return NextResponse.json({ success: true, data: assignData.data });
}

What does this code do?

This API route handles a POST request with a taskId and userId in the body. It performs two server-side operations before allowing the assignment:

  • It queries Hasura with the admin secret to fetch both the task and user records, checking their organization_id fields.
  • If the task and user belong to different organizations, it returns a 403 Forbidden response. This cross-table validation is impossible on the client without exposing the admin secret.
  • Only after validation passes does it execute the assignment mutation using admin privileges.

Why can't this logic live on the client?

The client-side Apollo Client sends requests with the user role and permission headers. It cannot query across organizations to validate the assignment. The admin secret provides unrestricted access to all data, so it must never leave the server.

Business rules that span multiple tables or require elevated access belong in API routes. Single-table CRUD that Hasura permissions already handle can stay as direct client mutations.

  • Save src/app/api/tasks/assign/route.ts.

You will not see a visible change yet. The next substep wires up a button that calls this route.

Seeing a TypeScript error in route.ts?

  • Make sure the file is inside src/app/api/tasks/assign/. Next.js uses the folder path to define the API endpoint.
  • Check that you exported POST as a named export, not a default export. Next.js App Router requires named exports matching HTTP methods.

Help me debug my Next.js API route for task assignment.

Add an assign button to the task card

The API route is ready. Now you need a way to call it from the browser. You will add an Assign button to each task card that sends the current user's ID and the task ID to your new endpoint.

  • In src/app/(dashboard)/tasks/page.tsx, find this line near the top of the TasksPage function:
const [newTitle, setNewTitle] = useState('');
  • Add this helper function and handler below it:
  // Read a cookie value by name for the current tenant context
  function getCookie(name: string): string {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop()?.split(';').shift() || '';
    return '';
  }

  // Call the API route to assign a task to the current user
  async function handleAssign(taskId: string) {
    const userId = getCookie('x-tenant-user-id');
    const res = await fetch('/api/tasks/assign', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ taskId, userId }),
    });
    const data = await res.json();
    if (data.success) {
      refetch();
    } else {
      alert(data.error || 'Assignment failed');
    }
  }

What does this code do?

  • getCookie reads the current tenant's user ID from the cookie that the dashboard layout sets when you switch tenants.
  • handleAssign sends a POST request to your API route with the task ID and user ID. If the server confirms success, it refetches the task list.
  • This is a direct fetch call to your own Next.js API route. It does not go through Apollo because the business logic lives on your server, not in Hasura directly.
  • Now find the status <select> element inside the task card's <div> with className="flex items-center justify-between mt-2".
  • Add an Assign button between the user name span and the status select:
                      <button
                        onClick={() => handleAssign(task.id)}
                        className="text-xs text-primary hover:text-primary-hover"
                      >
                        Assign
                      </button>

What's happening here?

The button calls handleAssign with the task's ID. Your API route then validates the assignment server-side and returns success or an error message. Compare this to the status dropdown directly above it, which fires a direct Apollo mutation. Same UI, different execution paths based on the complexity of the operation.

  • Save src/app/(dashboard)/tasks/page.tsx.
  • Refresh your browser at http://localhost:3000/tasks. You should see an Assign link on each task card between the user name and the status dropdown.
  • Click Assign on any task. If your database has an assigned_to column on the tasks table, the assignment succeeds. If the column does not exist yet, you will see a Hasura error in the alert.

Add the assigned_to column

If you see an error when clicking Assign, open the Hasura Console at http://localhost:8080/console. Navigate to the tasks table, click Modify, and add a column named assigned_to of type uuid (nullable). This is the column your mutation updates.

Assign button not appearing?

  • Confirm you placed the <button> element inside the task card's flex items-center justify-between mt-2 div.
  • Check that the dev server is still running. If it crashed, restart it with npm run dev.

Help me debug why my Assign button isn't showing up on the task cards.

Document the decision framework

Your route works, but future developers (including future you) need to know WHY this logic lives here instead of as a direct mutation. A decision framework comment at the top of the file makes the architectural reasoning explicit.

  • In src/app/api/tasks/assign/route.ts, scroll to the very top of the file (above the import statement).
  • Add this decision framework comment block at line 1:
/**
 * DECISION FRAMEWORK: When to use an API Route vs a Direct Mutation
 *
 * USE DIRECT CLIENT MUTATION (via Apollo useMutation) when:
 * - Hasura permissions fully cover the access control
 * - No server secrets are needed
 * - Single-table CRUD operation
 * - Example: CreateTask, UpdateTaskStatus
 *
 * USE A NEXT.JS API ROUTE when:
 * - Business logic spans multiple tables or requires validation
 * - You need admin/elevated access (admin secret must stay server-side)
 * - You're calling external services (email, Slack, etc.)
 * - You need to keep secrets off the client
 * - Example: AssignTask (validate user belongs to same org as task)
 */

Why document this in code?

This comment lives where decisions are made. When someone adds a new feature and wonders whether it should be a direct mutation or an API route, the framework is right there at the file they are looking at. Documentation that lives next to the code it explains is far more likely to be read and followed.

  • Save src/app/api/tasks/assign/route.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the complete src/app/api/tasks/assign/route.ts:

/**
 * DECISION FRAMEWORK: When to use an API Route vs a Direct Mutation
 *
 * USE DIRECT CLIENT MUTATION (via Apollo useMutation) when:
 * - Hasura permissions fully cover the access control
 * - No server secrets are needed
 * - Single-table CRUD operation
 * - Example: CreateTask, UpdateTaskStatus
 *
 * USE A NEXT.JS API ROUTE when:
 * - Business logic spans multiple tables or requires validation
 * - You need admin/elevated access (admin secret must stay server-side)
 * - You're calling external services (email, Slack, etc.)
 * - You need to keep secrets off the client
 * - Example: AssignTask (validate user belongs to same org as task)
 */

import { NextRequest, NextResponse } from 'next/server';

const HASURA_ENDPOINT = 'http://localhost:8080/v1/graphql';
const HASURA_ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET || 'myadminsecret';

export async function POST(request: NextRequest) {
  const { taskId, userId } = await request.json();

  if (!taskId || !userId) {
    return NextResponse.json(
      { error: 'taskId and userId are required' },
      { status: 400 }
    );
  }

  // Step 1: Fetch the task and user to validate they share an organization
  const validationQuery = `
    query ValidateAssignment($taskId: uuid!, $userId: uuid!) {
      tasks_by_pk(id: $taskId) {
        id
        organization_id
      }
      users_by_pk(id: $userId) {
        id
        organization_id
      }
    }
  `;

  const validationRes = await fetch(HASURA_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-hasura-admin-secret': HASURA_ADMIN_SECRET,
    },
    body: JSON.stringify({
      query: validationQuery,
      variables: { taskId, userId },
    }),
  });

  const validationData = await validationRes.json();
  const task = validationData.data?.tasks_by_pk;
  const user = validationData.data?.users_by_pk;

  if (!task || !user) {
    return NextResponse.json(
      { error: 'Task or user not found' },
      { status: 404 }
    );
  }

  if (task.organization_id !== user.organization_id) {
    return NextResponse.json(
      { error: 'Cannot assign task to user from a different organization' },
      { status: 403 }
    );
  }

  // Step 2: Perform the assignment using admin privileges
  const assignMutation = `
    mutation AssignTask($taskId: uuid!, $userId: uuid!) {
      update_tasks_by_pk(pk_columns: { id: $taskId }, _set: { assigned_to: $userId }) {
        id
        assigned_to
      }
    }
  `;

  const assignRes = await fetch(HASURA_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-hasura-admin-secret': HASURA_ADMIN_SECRET,
    },
    body: JSON.stringify({
      query: assignMutation,
      variables: { taskId, userId },
    }),
  });

  const assignData = await assignRes.json();

  return NextResponse.json({ success: true, data: assignData.data });
}

You now have a clear separation between client-side mutations and server-side business logic. Your task board demonstrates both patterns working together in the same app. Next up, the Secret Mission challenges you to replace the mock tenant switching with real authentication.

Secret mission

Wire Up Real Auth with NextAuth.js and Hasura JWT

Your task board currently uses cookie-based mock headers to simulate multi-tenancy. Real production apps need real authentication. In this secret mission, you will replace the mock tenant switcher with NextAuth.js and JWT-based authentication that Hasura validates on every request.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Docker containers (Hasura GraphQL Engine and PostgreSQL).
  • Docker volume (db_data) storing your PostgreSQL database.
  • Next.js project folder (taskboard) on your Desktop.
  • npm packages inside node_modules.
  • Generated GraphQL types in src/generated/graphql.ts.

Keep everything running

No action needed. Choose this if you're still actively building or want to continue experimenting with the task board.

  • Your Docker containers will keep running in the background.
  • The Hasura Console stays accessible at http://localhost:8080/console.
  • Your Next.js dev server will need to be restarted manually if you closed the terminal.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your files and database data stay intact for next time.

  • Stop the Next.js dev server by pressing Ctrl+C in its terminal window.
  • Stop the Docker containers by running docker compose stop from your project folder. This stops the containers without deleting them or the database volume.
  • To resume later, run docker compose start from the same folder. Your data will still be there.

Delete - I don't want to use this again

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

  • Stop and remove the Docker containers and volumes by running docker compose down -v from your project folder. The -v flag removes the database volume so your PostgreSQL data is deleted.
  • Delete the project folder by running rm -rf ~/Desktop/taskboard (macOS) or removing the taskboard folder from your Desktop in File Explorer (Windows).
  • Optionally, prune unused Docker images by running docker image prune to reclaim disk space from the Hasura and PostgreSQL images.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-grade multi-tenant task board from scratch using Next.js, Hasura, Apollo Client, and GraphQL Codegen.

You've learned how to:

  • Configure Hasura row-level permissions that enforce complete tenant isolation on a shared PostgreSQL database, proving multi-tenancy works by switching organizations and seeing different data from the same query.
  • Build a fully typed GraphQL workflow where Codegen generates hooks directly from your Hasura schema, giving you compile-time safety and autocomplete for every query and mutation.
  • Apply a clear decision framework for when to use direct client mutations (single-table CRUD covered by Hasura permissions) vs Next.js API routes (cross-table validation, admin secrets, external services).
  • Wire up real authentication with NextAuth.js and Hasura JWT to replace mock tenant switching with a production-ready auth flow.

Ready to quiz yourself?