Next.js API Routes & Hasura Actions

Build server-side logic with Next.js API routes and Hasura Actions.

Introduction

30 Second Summary

Sometimes a button click needs to do more than just save one record. It needs to validate inputs, check for duplicates, and make sure multiple inserts either all succeed together or all fail together.

In this project, you will learn what server-side API routes are, how they work in Next.js, and how Hasura Actions let you call that same server-side code through GraphQL. You'll solve a real scenario to make every concept concrete.

What You'll Build

A Next.js API route that validates data and creates a property with its units atomically, then the same solution wired up as a Hasura Action so the frontend just calls a normal GraphQL mutation.

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

  • A mental model of what an API route is, where it lives (route.ts, the api/ folder, the lib/ folder), and how each piece connects to the rest of your app.
  • A working Next.js API route that validates input, inserts a property with its units in one atomic transaction, and returns structured data.
  • A Hasura Action that calls your server code behind the scenes so the frontend stays unified on GraphQL.
  • Secret Mission: Add duplicate-address checking that queries the database before inserting and returns a helpful error if a match is found.

Are there any prerequisites?

This project assumes you can write a basic GraphQL query or mutation in Hasura Console, you have a Next.js project running locally, and you have Docker with Hasura and PostgreSQL already running.

Understand Why You Need Server-Side Code

You know the goal: when a landlord clicks "Add Property", your app needs to validate data, check for duplicates, and insert a property with all its units in one atomic operation.

But why can't a regular GraphQL mutation handle all of this from the browser? In this step, you'll break down exactly where plain mutations fall short and what "server-side code" actually means.

In this step, get ready to:
  • Understand why plain GraphQL mutations from the browser cannot handle the create-property scenario.
  • Learn what an API actually is in plain terms.
  • Map out the two approaches you'll build in the next steps.
Walk through why plain mutations break

Today, when your React frontend sends a GraphQL mutation to Hasura, Hasura inserts the data directly into your database. This works great for simple operations like updating a tenant's name.

But creating a property with units is not simple. Three specific problems make a browser-side mutation fall apart here.

  • Problem 1: Custom validation. You cannot run "if this address already exists for this tenant, don't insert" logic inside a plain mutation. The mutation either inserts or it doesn't. There is no conditional check step built in.
  • Problem 2: Atomic multi-table inserts. You want to insert a property AND 5 units as one all-or-nothing operation. If the property inserts but units fail, you need everything rolled back. Coordinating this from the browser means multiple round trips with no transaction safety.
  • Problem 3: Server-trusted data. The tenant_id must come from the logged-in user's session on the server. If you set it in the browser, a malicious user could fake it and insert properties into other tenants' accounts.

What is the solution?

You write code that runs on YOUR server, not in the browser. Server-side code is private, trusted, and powerful. It can validate, query the database for duplicates, set the tenant from a verified session, and wrap everything in a single transaction.

Understand what an API actually is

An API (Application Programming Interface) is just a URL that your code can call to DO something. It is not a webpage.

When your browser visits /api/properties/create, it is not loading a page to display. It is sending data to your server and getting a response back.

How does this fit into your Next.js app?

Your Next.js app can serve both webpages AND these API endpoints from the same codebase. The webpage code runs in the browser. The API endpoint code runs on the server.

The user never sees the API code. It is private, trusted, and has access to secrets like your database admin credentials.

Map out the two paths ahead

You are going to solve the create-property problem twice, using two different approaches. Both use the same server-side code. The difference is how the frontend talks to it.

  • Path A: Direct API route. Your React frontend calls YOUR Next.js API endpoint directly. This is like calling a REST endpoint. Your frontend sends a POST request to /api/properties/create and gets a response back.
  • Path B: Hasura Action. Your React frontend calls a GraphQL mutation as normal. But Hasura forwards that request to your Next.js API endpoint behind the scenes. This is called a Hasura Action. Same server code, different routing.

Why learn both approaches?

Path A teaches you the fundamentals of what an API route IS and how it works. Path B shows you how Hasura wraps that same concept into a cleaner GraphQL workflow.

By the end, you will know exactly when to use each approach in your property management system.

Now you have the full picture of the problem and the two solutions ahead. Next up, you'll learn the anatomy of a Next.js API route so you can start building Path A.

Learn the Anatomy of a Next.js API Route

You now know why server-side code is essential for operations like creating a property with units atomically. The next question is: where does that server code actually live in your project?

In Next.js, your project's folder structure directly determines which URLs your app responds to. Understanding this mapping lets you read any Next.js project and instantly know what its API endpoints are.

In this step, get ready to:
  • Map a file path to its corresponding API URL.
  • Understand what route.ts exports and how Next.js calls your functions.
  • Learn what the lib/ folder is for and what "helpers" means.
Map your folder structure to URLs

In the Next.js App Router, every folder inside app/ becomes a segment of your URL. The folder path IS the route path.

  • Open the Explorer sidebar in VS Code (the file tree on the left).
  • Look at your app/ folder.

Here is the mapping you need to remember:

app/api/properties/create/route.ts
│    │       │        │       └── The special file that handles requests
│    │       │        └── Fourth URL segment: /create
│    │       └── Third URL segment: /properties
│    └── Second URL segment: /api
└── The app directory (not part of the URL)

How does this mapping work?

The file at app/api/properties/create/route.ts becomes the URL http://localhost:3000/api/properties/create.

The special file name route.ts tells Next.js "this folder is an API endpoint, not a page." Compare this to page.tsx, which tells Next.js "this folder is a webpage a user can visit."

A route.ts and a page.tsx cannot exist in the same folder. One folder is either an API endpoint OR a webpage.

Is the api/ folder special?

No. The api/ folder is just a naming convention for organization. It has no magic.

You could name it app/backend/properties/create/route.ts and it would work at /backend/properties/create. The community convention is to use api/ so other developers instantly recognize where the endpoints live.

Understand what goes inside route.ts

Now you know WHERE to put the file. Let's look at WHAT goes inside it.

Inside route.ts, you export functions named after HTTP methods. Next.js calls the matching function when a request arrives.

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

export async function POST(request: NextRequest) {
  // Next.js calls this function when someone sends a POST request
  // to /api/properties/create
}

What does this structure mean?

  • export async function POST means "handle POST requests to this URL." You can also export GET, PUT, PATCH, DELETE, HEAD, and OPTIONS.
  • The request parameter contains everything the caller sent: the JSON body, headers, query parameters.
  • You return a NextResponse with data (usually JSON) and a status code (like 200 for success or 400 for bad input).
  • Both NextRequest and NextResponse are imported from next/server.

When someone sends a POST to /api/properties/create, Next.js finds the matching route.ts file and calls your POST function. Your function reads the input, does its work, and sends back a response.

Why is this server-side only?

Route Handlers are server-side only. The browser never downloads or sees this code.

This is what makes them safe for sensitive operations. You can use secrets, talk to your database with admin privileges, and run validation logic that no one can tamper with from the browser.

Understand the lib/ folder and helpers

Your API routes often need to do the same thing repeatedly. For example, every route that talks to Hasura needs to send a request with the admin secret header.

Instead of copying that code into every route.ts file, you write it once in a shared location. That shared location is the lib/ folder.

your-project/
├── app/
│   └── api/
│       └── properties/
│           └── create/
│               └── route.ts      ← The API endpoint
├── lib/
│   └── hasura.ts                 ← Reusable helper (talks to Hasura)
└── .env.local                    ← Environment variables (secrets)

What is a helper?

A "helper" is just a normal function in a normal file. Nothing magical.

In this project, lib/hasura.ts exports a function called hasuraAdminQuery that knows how to send a GraphQL query to Hasura with the x-hasura-admin-secret header. You write it once, and every API route can import and use it.

Helpers only run on the server because only server code (like route.ts) imports them. The browser never touches these files.

Reading unfamiliar Next.js projects

When you open any Next.js project and see an api/ folder, you now know it contains API endpoints. When you see a lib/ folder, you know it contains reusable helpers that support those endpoints.

You now have a complete mental map of where API code lives and what each piece does. Next up, you will actually build the helper file and the API route to solve the create-property-with-units problem.

Build the API Route Solution

You understand how a Next.js API route is structured. Now it is time to write one from scratch.

In this step, you will create a reusable helper that talks to Hasura with admin privileges, then build the full API endpoint that validates input and inserts a property with all its units atomically. Finally, you will test it with a real HTTP request and verify the data in your database.

In this step, get ready to:
  • Create a reusable Hasura helper function in the lib/ folder.
  • Build the POST handler at app/api/properties/create/route.ts.
  • Test the endpoint with curl and verify the result in Hasura Console.
Create the helper file

Your API route needs to send GraphQL queries to Hasura with the admin secret attached. Instead of writing that logic inside every route, you will create a single helper function that any route can import.

  • In VS Code, create a new folder called lib at the root of your Next.js project (the same level as your app/ folder).
  • Inside lib/, create a new file called hasura.ts.
  • Paste the following code into lib/hasura.ts:
const HASURA_GRAPHQL_URL = process.env.HASURA_GRAPHQL_URL!;
const HASURA_ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET!;

export async function hasuraAdminQuery<T = any>(
  query: string,
  variables?: Record<string, any>
): Promise<{ data?: T; errors?: any[] }> {
  const response = await fetch(HASURA_GRAPHQL_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-hasura-admin-secret": HASURA_ADMIN_SECRET,
    },
    body: JSON.stringify({ query, variables }),
  });

  const json = await response.json();
  return json;
}

What does this code do?

This file reads two environment variables (HASURA_GRAPHQL_URL and HASURA_ADMIN_SECRET) and exports a single function called hasuraAdminQuery.

When any route calls hasuraAdminQuery, it sends a POST request to your Hasura endpoint with the x-hasura-admin-secret header attached. This gives it full unrestricted access to your database, just like the Hasura Console.

  • Save lib/hasura.ts.

Why is the admin secret safe here?

This file only runs on the server. Route Handlers in Next.js are server-side only and never get bundled for the browser.

Environment variables without the NEXT_PUBLIC_ prefix are invisible to the browser. The admin secret stays on your server where no user can see it.

Now you need to create the environment variables that lib/hasura.ts reads from.

  • Create a new file called .env.local at the root of your Next.js project (same level as lib/).
  • Paste the following into .env.local:
HASURA_GRAPHQL_URL=http://localhost:8080/v1/graphql
HASURA_ADMIN_SECRET=your-admin-secret-here
  • Replace your-admin-secret-here with the actual admin secret you set when configuring your Hasura Docker container.
  • Save .env.local.

Not sure what your admin secret is?

Check your docker-compose.yml file. Look for the HASURA_GRAPHQL_ADMIN_SECRET environment variable under your Hasura service. That value is what you paste into .env.local.

help me find my Hasura admin secret in my Docker setup

Create the API route

With the helper ready, you can now build the actual endpoint. This route handles the full create-property-with-units flow: read the request body, validate inputs, generate a units array, send a nested insert mutation to Hasura, and return the result.

  • Create the folder path app/api/properties/create/ inside your app/ directory.
  • Inside that folder, create a new file called route.ts.
  • Add the imports and the helper function by pasting this code into app/api/properties/create/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { hasuraAdminQuery } from "@/lib/hasura";

function generateUnitsArray(unitCount: number) {
  return Array.from({ length: unitCount }, (_, i) => ({
    unit_number: `Unit ${i + 1}`,
  }));
}

What does this code do?

  • NextRequest and NextResponse are imported from next/server. These are the standard types for handling requests and building responses in Route Handlers.
  • hasuraAdminQuery is the helper you just created. The @/lib/hasura path uses Next.js path aliasing to point to your lib/ folder.
  • generateUnitsArray takes a number (like 3) and returns an array of unit objects: [{ unit_number: "Unit 1" }, { unit_number: "Unit 2" }, { unit_number: "Unit 3" }].
  • Below the generateUnitsArray function in the same file, add the POST handler:
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { name, address, unit_count, tenant_id } = body;

    // Validate input
    if (!name || !address || !unit_count || !tenant_id) {
      return NextResponse.json(
        { message: "Missing required fields: name, address, unit_count, tenant_id" },
        { status: 400 }
      );
    }

    if (unit_count < 1 || unit_count > 100) {
      return NextResponse.json(
        { message: "unit_count must be between 1 and 100" },
        { status: 400 }
      );
    }

    // Build the mutation with dynamic units array
    const units = generateUnitsArray(unit_count);

    const mutation = `
      mutation CreatePropertyWithUnits($name: String!, $address: String!, $tenant_id: uuid!) {
        insert_properties_one(object: {
          name: $name,
          address: $address,
          tenant_id: $tenant_id,
          units: {
            data: [${units.map((u) => `{ unit_number: "${u.unit_number}" }`).join(", ")}]
          }
        }) {
          id
          name
          address
          units {
            id
            unit_number
          }
        }
      }
    `;

    const result = await hasuraAdminQuery(mutation, {
      name,
      address,
      tenant_id,
    });

    if (result.errors) {
      return NextResponse.json(
        { message: "Failed to create property", errors: result.errors },
        { status: 500 }
      );
    }

    return NextResponse.json(
      { property: result.data.insert_properties_one },
      { status: 201 }
    );
  } catch (error) {
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    );
  }
}

What does the POST handler do?

Here is the full flow that runs when someone sends a POST request to /api/properties/create:

  • Read the JSON body and pull out name, address, unit_count, and tenant_id.
  • Validate that no fields are missing and that unit_count is between 1 and 100. If validation fails, return a 400 error immediately.
  • Build a nested insert mutation that inserts the property AND all its units in one shot. Hasura runs this as a single database transaction, so if anything fails, everything rolls back.
  • Send the mutation to Hasura using your hasuraAdminQuery helper.
  • Return the result with a 201 status on success or a 500 status if Hasura reports errors.
  • Save app/api/properties/create/route.ts.

Seeing red underlines in VS Code?

If you see errors on the @/lib/hasura import, your project may not have the path alias configured. Check that your tsconfig.json file includes a paths entry mapping @/* to ./*. Most Next.js projects created with create-next-app have this by default.

help me fix the @/lib/hasura import path alias in my Next.js project

✔️ Awesome, I've got everything!

Great. Double check you have saved both lib/hasura.ts and app/api/properties/create/route.ts.

ⓧ I'd like to double check the full code

Here is the full lib/hasura.ts:

const HASURA_GRAPHQL_URL = process.env.HASURA_GRAPHQL_URL!;
const HASURA_ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET!;

export async function hasuraAdminQuery<T = any>(
  query: string,
  variables?: Record<string, any>
): Promise<{ data?: T; errors?: any[] }> {
  const response = await fetch(HASURA_GRAPHQL_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-hasura-admin-secret": HASURA_ADMIN_SECRET,
    },
    body: JSON.stringify({ query, variables }),
  });

  const json = await response.json();
  return json;
}

Here is the full app/api/properties/create/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { hasuraAdminQuery } from "@/lib/hasura";

function generateUnitsArray(unitCount: number) {
  return Array.from({ length: unitCount }, (_, i) => ({
    unit_number: `Unit ${i + 1}`,
  }));
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { name, address, unit_count, tenant_id } = body;

    // Validate input
    if (!name || !address || !unit_count || !tenant_id) {
      return NextResponse.json(
        { message: "Missing required fields: name, address, unit_count, tenant_id" },
        { status: 400 }
      );
    }

    if (unit_count < 1 || unit_count > 100) {
      return NextResponse.json(
        { message: "unit_count must be between 1 and 100" },
        { status: 400 }
      );
    }

    // Build the mutation with dynamic units array
    const units = generateUnitsArray(unit_count);

    const mutation = `
      mutation CreatePropertyWithUnits($name: String!, $address: String!, $tenant_id: uuid!) {
        insert_properties_one(object: {
          name: $name,
          address: $address,
          tenant_id: $tenant_id,
          units: {
            data: [${units.map((u) => `{ unit_number: "${u.unit_number}" }`).join(", ")}]
          }
        }) {
          id
          name
          address
          units {
            id
            unit_number
          }
        }
      }
    `;

    const result = await hasuraAdminQuery(mutation, {
      name,
      address,
      tenant_id,
    });

    if (result.errors) {
      return NextResponse.json(
        { message: "Failed to create property", errors: result.errors },
        { status: 500 }
      );
    }

    return NextResponse.json(
      { property: result.data.insert_properties_one },
      { status: 201 }
    );
  } catch (error) {
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    );
  }
}

Here is the full .env.local:

HASURA_GRAPHQL_URL=http://localhost:8080/v1/graphql
HASURA_ADMIN_SECRET=your-admin-secret-here
Test the endpoint with curl

Your endpoint is live. Time to prove it works by sending a real HTTP request and checking that the data lands in your database.

  • Make sure your Next.js development server is still running. If you stopped it, restart it.
  • You need a valid tenant_id (a UUID from your tenants table). Switch to Hasura Console, open the tenants table, and copy any tenant's id value.
  • Open a terminal and send a POST request to your endpoint by running this command:
curl -X POST http://localhost:3000/api/properties/create \
  -H "Content-Type: application/json" \
  -d '{"name": "Sunset Apartments", "address": "123 Main Street", "unit_count": 3, "tenant_id": "[[TENANT_ID=\"your-tenant-uuid-from-hasura\"]]"}'

What should I see?

A successful response looks like JSON with a 201 status. It will include the new property's id, name, address, and an array of 3 units, each with their own id and unit_number.

  • Switch to Hasura Console in your browser.
  • Click the Data tab in the top navigation.
  • Open the properties table. You should see your new property ("Sunset Apartments") listed.
  • Open the units table. You should see 3 units (Unit 1, Unit 2, Unit 3) linked to that property.

This proves the full chain works. Your server received the request, validated it, talked to Hasura with admin privileges, inserted data atomically, and returned the result.

Getting a connection refused error?

Make sure your Next.js development server is running on port 3000. If it stopped, restart it from the terminal in your project folder.

If you get a 400 error about missing fields, check that your JSON body includes all four fields: name, address, unit_count, and tenant_id. Make sure tenant_id is a valid UUID from your database.

help me debug my curl request to the Next.js API route

Your API route is working end to end. Next up, you will solve this same problem using a Hasura Action, so the frontend can call it through GraphQL instead of a direct HTTP request.

Solve It with a Hasura Action

In Step 3, you proved your server-side code works. Your API route validates data, talks to Hasura with admin privileges, and inserts a property with units atomically.

Now you will solve the exact same problem a different way. Instead of your frontend calling your API route directly, you will tell Hasura to forward a GraphQL mutation to your server code behind the scenes. This is called a Hasura Action. From the frontend's perspective, it is just another GraphQL mutation.

In this step, get ready to:
  • Understand what a Hasura Action is and how it connects to your API route.
  • Create an Action handler that reads Hasura's wrapped payload format.
  • Wire up the Action in Hasura Console and test it from GraphiQL.
Understand what a Hasura Action is

Right now, when your frontend calls insert_properties through GraphQL, Hasura handles it directly. An Action flips this pattern.

With an Action, you tell Hasura: "I have a custom mutation called createPropertyWithUnits. When someone calls it, do NOT handle it yourself. Forward the request to THIS URL and return whatever it gives back."

What is a Hasura Action?

An Action is a custom GraphQL mutation (or query) that Hasura delegates to YOUR code. From the frontend's perspective, it looks like any other GraphQL mutation. From Hasura's perspective, it is a pass-through to your server.

The URL that Hasura calls is the handler URL (also called the webhook URL). Your API route that receives this call is the handler. It is the same API route concept from Step 3, just receiving a slightly different request format.

How does the handler payload differ?

When your frontend calls the API route directly (Step 3), the request body is simple: { name, address, unit_count, tenant_id }.

When Hasura calls your handler via an Action, it wraps the data differently: { input: { name, address, unit_count, tenant_id }, session_variables: { "x-hasura-user-id": "...", "x-hasura-role": "..." }, action: { name: "createPropertyWithUnits" } }.

So your handler reads from body.input instead of body directly. Everything else (validation, Hasura call, response) is the same logic.

Create the Action handler route

You will create a second route file that handles requests from Hasura. The logic is nearly identical to Step 3's route, with two key differences: it reads input from body.input, and it returns data in the format Hasura expects.

  • Create the folder path app/api/actions/create-property/ inside your Next.js project.
  • Create a new file called route.ts inside that folder.
  • Paste the following code into app/api/actions/create-property/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { hasuraAdminQuery } from "@/lib/hasura";

function generateUnitsArray(unitCount: number) {
  return Array.from({ length: unitCount }, (_, i) => ({
    unit_number: `Unit ${i + 1}`,
  }));
}

export async function POST(request: NextRequest) {
  try {
    // Hasura Action sends data in a DIFFERENT format than a direct call.
    // The actual input is nested inside body.input
    // Session info is in body.session_variables
    const body = await request.json();
    const { name, address, unit_count, tenant_id } = body.input;
    const sessionVariables = body.session_variables;

    // Validate input
    if (!name || !address || !unit_count || !tenant_id) {
      return NextResponse.json(
        { message: "Missing required fields: name, address, unit_count, tenant_id" },
        { status: 400 }
      );
    }

    if (unit_count < 1 || unit_count > 100) {
      return NextResponse.json(
        { message: "unit_count must be between 1 and 100" },
        { status: 400 }
      );
    }

    // Build the mutation with dynamic units array
    const units = generateUnitsArray(unit_count);

    const mutation = `
      mutation CreatePropertyWithUnits($name: String!, $address: String!, $tenant_id: uuid!) {
        insert_properties_one(object: {
          name: $name,
          address: $address,
          tenant_id: $tenant_id,
          units: {
            data: [${units.map((u) => `{ unit_number: "${u.unit_number}" }`).join(", ")}]
          }
        }) {
          id
          name
          address
          units {
            id
            unit_number
          }
        }
      }
    `;

    const result = await hasuraAdminQuery(mutation, {
      name,
      address,
      tenant_id,
    });

    if (result.errors) {
      // For Actions, return 400 with a message field.
      // Hasura reads this message and sends it to the GraphQL client.
      return NextResponse.json(
        { message: "Failed to create property" },
        { status: 400 }
      );
    }

    // For Actions, return the data directly matching your output type.
    // No wrapper object. Just the fields Hasura expects.
    return NextResponse.json(result.data.insert_properties_one);
  } catch (error) {
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    );
  }
}

What is different from the direct route?

  • body.input: The input fields are nested inside body.input because Hasura wraps the caller's arguments.
  • body.session_variables: Hasura sends the caller's session info (role, user ID) so your handler can make auth decisions.
  • Error responses use status 400 with a message field. Hasura reads this message and forwards it to the GraphQL client as a mutation error.
  • Success responses return the data directly (no wrapper object like { property: ... }). Hasura expects the fields to match your defined output type exactly.
  • Save app/api/actions/create-property/route.ts.

You will not see a change yet. The next substep wires this handler up in Hasura Console so you can test it.

Seeing a module not found error?

Make sure the import path @/lib/hasura resolves correctly. The @/ alias should point to your project root. If your tsconfig.json does not define this alias, check that it includes a paths entry mapping @/* to ./*.

help me fix my import path for the Hasura helper

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

import { NextRequest, NextResponse } from "next/server";
import { hasuraAdminQuery } from "@/lib/hasura";

function generateUnitsArray(unitCount: number) {
  return Array.from({ length: unitCount }, (_, i) => ({
    unit_number: `Unit ${i + 1}`,
  }));
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { name, address, unit_count, tenant_id } = body.input;
    const sessionVariables = body.session_variables;

    if (!name || !address || !unit_count || !tenant_id) {
      return NextResponse.json(
        { message: "Missing required fields: name, address, unit_count, tenant_id" },
        { status: 400 }
      );
    }

    if (unit_count < 1 || unit_count > 100) {
      return NextResponse.json(
        { message: "unit_count must be between 1 and 100" },
        { status: 400 }
      );
    }

    const units = generateUnitsArray(unit_count);

    const mutation = `
      mutation CreatePropertyWithUnits($name: String!, $address: String!, $tenant_id: uuid!) {
        insert_properties_one(object: {
          name: $name,
          address: $address,
          tenant_id: $tenant_id,
          units: {
            data: [${units.map((u) => `{ unit_number: "${u.unit_number}" }`).join(", ")}]
          }
        }) {
          id
          name
          address
          units {
            id
            unit_number
          }
        }
      }
    `;

    const result = await hasuraAdminQuery(mutation, {
      name,
      address,
      tenant_id,
    });

    if (result.errors) {
      return NextResponse.json(
        { message: "Failed to create property" },
        { status: 400 }
      );
    }

    return NextResponse.json(result.data.insert_properties_one);
  } catch (error) {
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    );
  }
}
Wire up the Action in Hasura Console

Your handler code is ready. Now you need to tell Hasura about it by creating an Action in the Console.

  • Switch back to Hasura Console in your browser (http://localhost:8080).
  • Click the Actions tab in the top navigation.
  • Click Create.

You will see two editors: Action Definition and New types definition.

  • Clear the placeholder text in the Action Definition editor and paste the following:
type Mutation {
  createPropertyWithUnits(
    name: String!
    address: String!
    unit_count: Int!
    tenant_id: uuid!
  ): CreatePropertyOutput
}
  • Clear the placeholder text in the New types definition editor and paste the following:
type CreatePropertyOutput {
  id: uuid!
  name: String!
  address: String!
  units: [UnitOutput!]!
}

type UnitOutput {
  id: uuid!
  unit_number: String!
}

What do these definitions do?

The Action Definition tells Hasura the shape of your custom mutation. It defines the input arguments (name, address, unit_count, tenant_id) and the return type (CreatePropertyOutput).

The New types definition describes what the return type looks like. This tells Hasura how to validate and expose the response in your GraphQL schema.

  • Scroll down to the Handler field (also labeled "Webhook Handler" in some versions).
  • Enter the handler URL. Since Hasura runs inside Docker and your Next.js app runs on your host machine, use:
http://host.docker.internal:3000/api/actions/create-property

Why host.docker.internal?

Hasura is running inside a Docker container. It cannot use localhost to reach your Next.js app because localhost inside the container refers to the container itself.

host.docker.internal is a special DNS name that Docker provides on Mac and Windows. It resolves to your host machine's IP, letting the container reach services running outside Docker.

If your Next.js app is ALSO in Docker on the same network, use the service name instead (e.g., http://nextjs-app:3000/api/actions/create-property).

  • Click Create at the bottom of the page to save your Action.

Hasura now knows about your custom mutation. Time to test it.

  • Click the API tab in the top navigation to open GraphiQL.
  • Paste the following mutation into the GraphiQL editor and click the play button to execute it:
mutation {
  createPropertyWithUnits(
    name: "Riverdale Apartments"
    address: "789 River Road"
    unit_count: 3
    tenant_id: "replace-with-a-valid-tenant-uuid"
  ) {
    id
    name
    address
    units {
      id
      unit_number
    }
  }
}

What should I see?

You should see a JSON response with the created property's id, name, address, and an array of 3 units (Unit 1, Unit 2, Unit 3). This proves Hasura received the mutation, forwarded it to your handler, your handler inserted the data, and Hasura returned the result to GraphiQL.

Seeing a connection error or timeout?

If Hasura cannot reach your handler, check these common causes:

  • Make sure your Next.js dev server is still running at http://localhost:3000.
  • Verify the handler URL is exactly http://host.docker.internal:3000/api/actions/create-property. A typo in the path means Hasura gets a 404.
  • On Linux, host.docker.internal may not work by default. Add --add-host=host.docker.internal:host-gateway to your Docker run command or add the equivalent in docker-compose.yml.

help me debug why Hasura cannot reach my Action handler

Your Action is working. The frontend now calls a normal GraphQL mutation and gets data back. It has no idea your custom server code ran behind the scenes. Next up, you will learn when to use each approach so you can make the right choice for every situation in your app.

Decide When to Use Each Approach

You just solved the same problem two different ways. Your direct API route at app/api/properties/create/route.ts handles requests from your frontend. Your Hasura Action handler at app/api/actions/create-property/route.ts does the same work, but Hasura calls it on behalf of the frontend.

So which one should you actually use in your property management system? The answer depends on what kind of operation you are performing.

In this step, get ready to:
  • Learn a decision framework for choosing between plain mutations, Actions, and direct API routes.
  • Understand whether Actions can completely replace API routes in your stack.
Apply the decision framework to your property management system

Every time you need to do something with data in your app, you have three options. Each one fits a different situation.

Option 1: Regular GraphQL mutations (no API route needed)

For simple inserts and updates where Hasura's built-in permissions are enough, you do not need any server-side code at all. The frontend sends a GraphQL mutation directly to Hasura, and Hasura handles it.

Examples from your property management system:

  • Updating a tenant's name or email address.
  • Adding a note to a property.
  • Marking a maintenance request as complete.

Why no API route here?

These operations are straightforward. There is no custom validation to run, no multi-table transaction to coordinate, and no external service to call. Hasura's permission system already controls who can do what.

Option 2: Hasura Actions (custom logic through GraphQL)

Use an Action when you need custom server-side logic AND the frontend should call it through GraphQL like any other mutation. From the frontend's perspective, it looks identical to a regular mutation.

Examples from your property management system:

  • Creating a property with units atomically (the scenario you just solved).
  • Inviting a user by email (validate the email, check if the user exists, send an invite).
  • Running a rent calculation before saving a lease (compute prorated amounts based on move-in date).

Why Actions win here

The frontend stays simple. Every mutation goes through the same GraphQL endpoint. The frontend developer does not need to know about REST endpoints or different request formats.

Hasura also gives you permissions and session variables for free. Your handler receives session_variables with the user's role and ID without any extra work.

Option 3: Direct API routes (non-GraphQL integrations)

Use a direct API route when the operation is not GraphQL-shaped. These are cases where an external system needs to call YOUR server, or where the request/response format does not fit a GraphQL mutation.

Examples from your property management system:

  • Uploading a file to Nhost Storage (multipart form data, not JSON).
  • Receiving a webhook from Stripe when a rent payment succeeds (Stripe calls YOUR endpoint).
  • Serving a CSV export download of all properties (returns a file, not JSON).
  • Handling OAuth callbacks from third-party services.

Why not an Action here?

Actions only work when YOUR frontend initiates the call through GraphQL. External systems like Stripe do not speak GraphQL. They POST to a URL you give them. That URL is a direct API route.

Understand whether Actions can replace API routes entirely

For all your database mutations that need custom logic, the answer is yes. Actions are the better choice because they keep your frontend unified around a single GraphQL endpoint.

You still need direct API routes for external integrations. Webhooks, file uploads, and OAuth callbacks will always need their own endpoints.

The recommendation for your stack

Default to Actions for custom data logic. Your frontend stays clean (just GraphQL mutations), and Hasura handles permissions, session variables, and the unified endpoint.

Keep direct API routes for non-GraphQL integrations. Anything where an external system calls you, or where the data format is not JSON, belongs in a direct route.ts file.

What about the direct route you built in Step 3?

The app/api/properties/create/route.ts file you built earlier works perfectly. But now that you also have the Action version, the Action is the better production choice for this scenario. The frontend calls a GraphQL mutation just like every other data operation. No special REST call needed.

Secret mission

Add Duplicate-Address Checking

What happens if a landlord accidentally adds the same property twice? Right now, your API happily inserts duplicates. In this secret mission, add a pre-insert check that catches duplicate addresses and returns a helpful error before any data gets written.

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 on your local machine using Docker containers. There are no cloud costs or ongoing charges.

Resources you used:

  • Docker containers (Hasura GraphQL Engine and PostgreSQL).
  • Next.js development server running at http://localhost:3000.
  • Project files (API routes, helper library, environment variables).
  • Hasura Action configuration (createPropertyWithUnits stored in Hasura metadata).
  • Test data in PostgreSQL (properties and units created during testing).

Keep everything running

No action needed. Choose this if you are still actively building your property management system and want to pick up where you left off.

  • Your Docker containers continue running in the background.
  • Your Next.js dev server stays available at http://localhost:3000.
  • Hasura Console remains accessible at http://localhost:8080.
  • All test data and the Hasura Action configuration persist.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your project files and Docker volumes stay intact so you can restart later.

  • Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
  • Stop your Docker containers by running this command from the folder containing your docker-compose.yml:
docker compose stop

To restart later, run docker compose start from the same folder, then start your Next.js dev server again.

Delete - I don't want to use this again

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

  • Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
  • Remove Docker containers and their volumes (this deletes all PostgreSQL data and Hasura metadata, including the Action configuration) by running:
docker compose down -v
  • Delete the project files. On macOS/Linux:
rm -rf app/api/properties
rm -rf app/api/actions
rm -rf lib/hasura.ts
rm .env.local
  • On Windows (PowerShell):
Remove-Item -Recurse -Force app\api\properties
Remove-Item -Recurse -Force app\api\actions
Remove-Item -Force lib\hasura.ts
Remove-Item -Force .env.local
  • If you also want to remove the lib/ folder entirely (only if it contains nothing else):
rmdir lib

Your Docker images for Hasura and PostgreSQL remain cached on your machine. They do not consume active resources but take disk space. Remove them with docker image prune if you want to reclaim that space.

Nice Work!

Nice Work!

You did it. You now understand the full picture of how server-side code works in your Next.js and Hasura stack.

You've learned how to:

  • Built a Next.js API route that validates input, talks to Hasura with admin privileges, and inserts a property with all its units atomically.
  • Created a Hasura Action that forwards a GraphQL mutation to your custom handler, keeping the frontend unified on a single GraphQL endpoint.
  • Understood the full architecture of route.ts, the api/ folder, lib/ helpers, Docker networking, and when to use plain mutations vs Actions vs direct API routes.
  • Added duplicate-address validation that queries the database before inserting and returns a helpful error with the existing property's ID.

Ready to quiz yourself?