Skip to content
KheAi
Go back

GraphQL API + Next.js Frontend

Edit page

This is Part 7 of 24 in the NestJS series. Part 6 established the CQRS request pipeline — commands, queries, handlers, and the service layer. This part builds the API surface on top of that pipeline: DTOs that define the GraphQL schema, resolvers that expose queries and mutations, and the Next.js frontend that consumes them.

What This Part Covers

Backend:

Frontend:


1. DTOs — The API Contract

DTOs (Data Transfer Objects) define the shape of data at the API boundary. They are separate from entities because the API contract and the database schema are different concerns:

Client request → InputDTO → (validated, transformed) → Service → Entity → Database
Database → Entity → (mapped) → OutputDTO → Client response

1.1 Output DTO (@ObjectType)

The @ObjectType DTO defines what GraphQL returns to clients.

AbstractDto — the standard response envelope: AbstractDto (from nestjs-dev-utilities) is the base class all output DTOs extend. It exposes id, createdAt, and updatedAt as @Field() automatically. If AbstractEntity is the company letterhead for database rows, AbstractDto is the standard response envelope for API responses — the client always knows where to find the id and timestamps because they appear on every envelope.

From Meteor? Minimongo documents had no fixed shape — any fields could be present or absent. @ObjectType with AbstractDto enforces a contract: only the fields you declare with @Field() or @FilterableField() are visible to clients. Every other property is invisible, even if it exists on the underlying entity.

// apps/api/src/modules/todo/dto/todo.dto.ts
import { Field, Int, ObjectType } from "@nestjs/graphql";
import { FilterableField } from "@ptc-org/nestjs-query-graphql";
import { AbstractDto } from "nestjs-dev-utilities";
import { TodoStatus } from "../todo.constant";

@ObjectType("Todo") // ← 'Todo' is the name in the GraphQL schema
export class TodoDto extends AbstractDto {
  // AbstractDto provides: id (Int!), createdAt (DateTime!), updatedAt (DateTime!)

  @FilterableField() // ← clients can filter by text: { text: { like: "%milk%" } }
  text: string;

  @FilterableField() // ← clients can filter by isChecked: { isChecked: { is: true } }
  isChecked: boolean;

  @FilterableField(() => TodoStatus) // ← clients can filter by status
  status: TodoStatus;

  // userId is NOT exposed as a @Field — it's an internal column
  // Clients should not be able to filter todos by arbitrary userId
  // Security: exposing userId enables enumeration of other users' data
}

@Field vs @FilterableField:

DecoratorWhat it doesWhen to use
@Field()Exposes the field in GraphQL responses. Clients can request it but cannot filter by it.Fields that are returned but shouldn’t be filterable (e.g., color, description)
@FilterableField()Exposes the field AND registers it with nestjs-query’s filter system. Clients can use it in filter: { fieldName: { eq: "value" } }.Fields you want to support filtering on

Security rule: Only add @FilterableField to columns you explicitly support filtering on. Never add it to internal columns like password, twoFactorSecret, or FK IDs that expose data from other tenants.

Intake form: The DTO is the intake form at the hospital entrance. Before anything reaches your service, it must declare its exact contents. The customs hall (ValidationPipe) checks the form. Undeclared fields? Confiscated (whitelist: true). Unknown fields? The entire form is rejected (forbidNonWhitelisted: true). Malformed form? Turned away at the entrance. Only clean, certified data reaches your specialist.

Memory hook: @FilterableField = field appears in responses AND is filterable by clients. @Field = appears in responses only. Neither = invisible to clients entirely.

1.2 Input DTOs (@InputType)

Input DTOs define what clients send in mutations. class-validator decorators enforce validation before the resolver method runs.

// apps/api/src/modules/todo/dto/todo.input.ts
import { Field, InputType, Int } from "@nestjs/graphql";
import {
  IsBoolean,
  IsEnum,
  IsInt,
  IsNotEmpty,
  IsOptional,
  IsString,
  MaxLength,
} from "class-validator";
import { TodoStatus } from "../todo.constant";

@InputType()
export class CreateTodoInput {
  @Field()
  @IsString()
  @IsNotEmpty({ message: "Todo text cannot be empty" })
  @MaxLength(500, { message: "Todo text cannot exceed 500 characters" })
  text: string;

  // Part 08: userId removed from @Field and injected from JWT instead
  @Field(() => Int)
  @IsInt()
  userId: number;
}

@InputType()
export class UpdateTodoInput {
  @Field({ nullable: true })
  @IsOptional()
  @IsString()
  @IsNotEmpty()
  @MaxLength(500)
  text?: string;

  @Field({ nullable: true })
  @IsOptional()
  @IsBoolean()
  isChecked?: boolean;

  @Field(() => TodoStatus, { nullable: true })
  @IsOptional()
  @IsEnum(TodoStatus)
  status?: TodoStatus;
}

From Meteor? check(input, String) inside a Meteor method is the rough equivalent — but it was optional, per-method, and didn’t strip unknown fields. class-validator decorators on an @InputType combined with the global ValidationPipe({ whitelist: true }) are automatic, global, and reject requests with undeclared fields before your resolver method even starts.

Memory hook: @InputType = intake form (what clients can submit). class-validator decorators = the rules printed on the form. ValidationPipe = the customs hall that enforces them globally.

Note: userId is a @Field() for now — Part 08 fixes this.

In production, exposing userId as a client-supplied field is a security risk: a malicious client could send userId: 999 and create todos that appear to belong to another user. Part 08 adds JWT authentication — at that point userId is removed from @Field() and injected server-side from the verified token instead. For this part, the endpoints are public and userId is passed explicitly so you can test without auth.

1.3 Query Args DTO (@ArgsType)

For list queries with filtering, sorting, and pagination, nestjs-query generates a complete filter/sort/pagination argument type automatically:

// apps/api/src/modules/todo/dto/todo.query.ts
import { ArgsType } from "@nestjs/graphql";
import { SortDirection } from "@ptc-org/nestjs-query-core";
import { PagingStrategies, QueryArgsType } from "@ptc-org/nestjs-query-graphql";
import { TodoDto } from "./todo.dto";

@ArgsType()
export class TodosQuery extends QueryArgsType(TodoDto, {
  defaultSort: [{ field: "createdAt", direction: SortDirection.DESC }],
  pagingStrategy: PagingStrategies.CURSOR, // Relay cursor pagination
  enableTotalCount: true,
}) {}

// This exports the Connection type for use in the resolver return type
export const TodoQueryConnection = TodosQuery.ConnectionType;

QueryArgsType(TodoDto) auto-generates a GraphQL argument type that includes:

This means you get free filtering, sorting, and pagination on every list query — with zero extra code.

From Meteor? Collection.find({}, { sort: { createdAt: -1 }, limit: 20 }) gave you basic sorting and limiting, but no cursor pagination, no typed filter objects, and no auto-generated GraphQL argument types. QueryArgsType generates a full GraphQL filter/sort/paging argument schema from your DTO’s @FilterableField declarations — zero boilerplate.

Memory hook: QueryArgsType(TodoDto) = auto-generated filter + sort + paging args for every @FilterableField. ConnectionType = the Relay cursor response wrapper. One declaration, full pagination.


2. Cursor Pagination vs Offset Pagination

The query above uses PagingStrategies.CURSOR. This is important to understand.

Offset Pagination (the Meteor/SQL beginner way)

SELECT * FROM todo WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10 OFFSET 100;

The database must scan and discard 100 rows before returning the next 10. On 10 million rows, page 1000 scans 10,000 rows. Gets exponentially slower as you go deeper.

Cursor Pagination (the enterprise way)

SELECT * FROM todo WHERE user_id = 1 AND created_at < '2024-01-15T10:00:00Z'
ORDER BY created_at DESC LIMIT 10;

The database uses the index to jump directly to the cursor position. Constant cost regardless of how deep into the list you are.

The Relay cursor response shape:

{
  getTodos {
    totalCount # total matching records
    edges {
      cursor # opaque string encoding this record's position
      node {
        id
        text
        isChecked
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}

To get the next page, pass endCursor as after:

{
  getTodos(paging: { first: 10, after: "eyJpZCI6MTB9" }) {
    edges {
      node {
        id
        text
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

From Meteor? Meteor’s Collection.find() with skip and limit is offset pagination — it scans and discards rows. There was no built-in cursor pagination. PagingStrategies.CURSOR uses the index to jump directly to the cursor position; cost is constant whether you are on page 1 or page 10,000.

Memory hook: Cursor pagination = jump to position via index (constant cost). Offset pagination = scan and skip (cost grows with depth). Use PagingStrategies.CURSOR for all list queries.


3. The Resolver

The personal shopper: A REST controller is a traffic cop at a fixed intersection — five routes, all cars must choose one, you often need multiple trips to assemble what you want. A GraphQL resolver is a personal shopper at a department store. The client tells the shopper exactly what it wants: “Give me the product name, its category, and the three most recent reviews — nothing else.” The shopper fetches that precise shape in one trip. No over-fetching. No under-fetching. No three separate API calls.

From Meteor? Meteor.methods({ createTodo }) handled both reads and writes in one block, and Meteor.publish('todos') handled live data. In NestJS, @Mutation() = the write path (Meteor method), @Query() = the read path (Meteor publication) — but both are explicit, typed, transported over standard HTTPS, and separated into their own decorated methods.

The resolver is the GraphQL entry point — the Meteor Method and Publication combined into one class, but separated into @Query (reads) and @Mutation (writes).

Part 07 vs Part 08: This resolver has no auth guards. All endpoints are publicly accessible. Part 08 adds JWT authentication: @UseGuards(AuthJwtGuard) is added to protected queries/mutations, @CurrentUser() replaces the explicit userId input field, and per-user data isolation is enforced via userId filters.

// apps/api/src/modules/todo/todo.resolver.ts
import { Args, Int, Mutation, Query, Resolver } from "@nestjs/graphql";
import { CommandBus, QueryBus } from "@nestjs/cqrs";

import { TodoDto } from "./dto/todo.dto";
import { CreateTodoInput, UpdateTodoInput } from "./dto/todo.input";
import { TodosQuery, TodoQueryConnection } from "./dto/todo.query";
import {
  CountTodoQuery,
  CreateOneTodoCommand,
  DeleteOneTodoCommand,
  FindManyTodoQuery,
  FindOneTodoQuery,
  UpdateOneTodoCommand,
} from "./cqrs";

@Resolver(() => TodoDto)
export class TodoResolver {
  constructor(
    private readonly queryBus: QueryBus,
    private readonly commandBus: CommandBus
  ) {}

  // ── Queries (reads) ─────────────────────────────────────────────

  @Query(() => TodoDto, { nullable: true })
  async todo(
    @Args("id", { type: () => Int }) id: number
  ): Promise<TodoDto | null> {
    const { data } = await this.queryBus.execute(
      new FindOneTodoQuery({ query: { filter: { id: { eq: id } } } })
    );
    return data as TodoDto;
  }

  @Query(() => TodoQueryConnection)
  async getTodos(@Args() query: TodosQuery) {
    return TodoQueryConnection.createFromPromise(
      async q => {
        const { data } = await this.queryBus.execute(
          new FindManyTodoQuery({ query: q })
        );
        return data as TodoDto[];
      },
      query,
      async filter => {
        const { data: count } = await this.queryBus.execute(
          new CountTodoQuery({ query: filter })
        );
        return count as number;
      }
    );
  }

  // ── Mutations (writes) ──────────────────────────────────────────

  @Mutation(() => TodoDto)
  async createTodo(@Args("input") input: CreateTodoInput): Promise<TodoDto> {
    const { data } = await this.commandBus.execute(
      new CreateOneTodoCommand({ input })
    );
    return data as TodoDto;
  }

  @Mutation(() => TodoDto)
  async updateTodo(
    @Args("id", { type: () => Int }) id: number,
    @Args("input") input: UpdateTodoInput
  ): Promise<TodoDto> {
    const { data } = await this.commandBus.execute(
      new UpdateOneTodoCommand({
        query: { filter: { id: { eq: id } } },
        input,
      })
    );
    return data.updated as TodoDto;
  }

  @Mutation(() => Boolean)
  async deleteTodo(
    @Args("id", { type: () => Int }) id: number
  ): Promise<boolean> {
    await this.commandBus.execute(new DeleteOneTodoCommand({ input: id }));
    return true;
  }
}

Memory hook: Resolver = personal shopper + receptionist. @Query reads, @Mutation writes. Dispatches to CommandBus/QueryBus. Zero business logic — if a resolver method has an if statement, it belongs in the Service.

What Part 08 changes in updateTodo:

After auth is added, the filter will be:

query: { filter: { id: { eq: id }, userId: { eq: currentUser.user.id } } }

The userId filter is included alongside id. If a malicious user sends id: 999 (another user’s todo), the database query is WHERE id = 999 AND user_id = <their user id> — which returns nothing. The update silently finds no record and fails safe.


4. FilterQueryBuilder

FilterQueryBuilder from @ptc-org/nestjs-query-typeorm translates GraphQL filter objects into TypeORM query builder calls:

// A GraphQL filter:
const graphqlFilter = {
  filter: {
    and: [{ status: { eq: "ACTIVE" } }, { text: { like: "%milk%" } }],
  },
  sorting: [{ field: "createdAt", direction: "DESC" }],
};

// FilterQueryBuilder translates this to:
// SELECT * FROM todo
// WHERE status = 'ACTIVE'
//   AND text ILIKE '%milk%'
// ORDER BY created_at DESC

You instantiate it in the service constructor:

this.filterQueryBuilder = new FilterQueryBuilder<TodoEntity>(this.repo);

// Then use it:
const builder = this.filterQueryBuilder.select(query); // query = { filter, sorting }
const results = await builder.getMany();
const single = await builder.getOne();

The select(query) method handles all the filter/sort translation automatically. You never write queryBuilder.where('text LIKE :text', { text }) by hand.

From Meteor? Minimongo’s query operators ({ text: { $regex: /milk/ } }) were translated client-side and matched against in-memory data. FilterQueryBuilder translates the equivalent GraphQL filter objects into real SQL on the server — with index-backed queries, tenant isolation via @Authorize, and support for complex nested AND/OR conditions.

Memory hook: FilterQueryBuilder = smart search desk. Hand it a nestjs-query filter/sort/paging spec; it produces a TypeORM query builder with @Authorize filters merged in automatically. You never write raw WHERE clauses.


5. Frontend — Next.js Setup

With the backend GraphQL API in place, you now build the frontend that consumes it.

5.1 Verify Next.js App Exists

From the Nx workspace root, apps/web should already exist from Part 02. If not:

npx nx g @nx/next:app apps/web --src=true --appDir=true --style=tailwind

5.2 Install Frontend Dependencies

# From workspace root:
yarn add @apollo/client graphql@16
yarn add --dev @graphql-codegen/cli@5 @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo

Node version gotcha: @apollo/client without a version pin installs v4 + graphql@17, which requires Node 22+. This project runs Node 20 — pin to graphql@16 as shown above. Same applies to @graphql-codegen/cli: v6 pulls in listr2@10 which also requires Node 22 — pin to @5.

5.3 Initialize Shadcn UI (Nx Monorepo)

Nx gotcha: apps/web has no package.json — deps are managed at the workspace root. Running npx shadcn init directly from apps/web detects no package.json and scaffolds a brand-new standalone Next.js project inside it. Do not do this.

The correct approach is four manual steps.

Step 1 — Install runtime deps at the workspace root:

# From workspace root:
yarn add @base-ui/react class-variance-authority clsx lucide-react tailwind-merge

Step 2 — Create apps/web/components.json:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/app/global.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "rtl": false,
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

Step 3 — Add @/* path alias to apps/web/tsconfig.json:

Add baseUrl and paths inside the existing compilerOptions — do not replace the file:

{
  "compilerOptions": {
    "...existing options...": "...",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Step 4 — Generate components via a temp subdir, then transplant:

Shadcn’s CLI needs a package.json to install deps. The trick: let it scaffold inside apps/web, generate all components at once, copy the source files to src/, then delete the temp folder.

cd apps/web
npx shadcn@latest init -d          # creates apps/web/enterprise-todo-ui/
cd enterprise-todo-ui
npx shadcn@latest add button input card checkbox badge
cd ..

# Copy component source files into the real Nx app
mkdir -p src/components/ui src/lib src/hooks
cp enterprise-todo-ui/components/ui/*.tsx src/components/ui/
cp enterprise-todo-ui/lib/utils.ts src/lib/utils.ts

# Delete the temp scaffold
rm -rf enterprise-todo-ui
cd ../..  # back to workspace root

You now have apps/web/src/components/ui/ with button, input, card, checkbox, and badge. These are your components — you own the code and can customize them freely. Unlike traditional component libraries, Shadcn ships source code, not compiled packages.

Meteor analogy: Instead of PicoCSS providing global semantic styles, Shadcn gives you pre-built accessible component primitives (Button, Input, Card, Checkbox) built on Base UI primitives, styled with Tailwind. You compose them to build your UI.

5.4 Upgrade to Tailwind v4

Shadcn v4 (base-nova style) generates components that use Tailwind v4 arbitrary CSS variable syntax (e.g. [--card-spacing:--spacing(4)]). The Nx-generated app ships Tailwind v3 — this causes a CSS parse error at runtime. Upgrade:

Step 1 — Install Tailwind v4 and its PostCSS plugin:

# From workspace root:
yarn add tailwindcss@^4 @tailwindcss/postcss@^4

Step 2 — Replace apps/web/postcss.config.js:

module.exports = {
  plugins: {
    '@tailwindcss/postcss': {},
  },
};

Step 3 — Update apps/web/src/app/global.css (first line only):

Replace:

@tailwind base;
@tailwind components;
@tailwind utilities;

With:

@import "tailwindcss";

Step 4 — Delete apps/web/tailwind.config.js:

Tailwind v4 auto-detects content — the v3 config file is not needed and can confuse the build.

rm apps/web/tailwind.config.js

6. Apollo Client Setup

// apps/web/src/lib/apollo-client.ts
import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  from,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";

const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3333/graphql",
});

// Log errors in development
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (process.env.NODE_ENV === "development") {
    graphQLErrors?.forEach(({ message, locations, path }) =>
      console.error(`GraphQL error: ${message}`, { locations, path })
    );
    if (networkError) console.error("Network error:", networkError);
  }
});

// Part 08: adds an authLink that reads localStorage.getItem('accessToken')
// and injects Authorization: Bearer <token> into every request

export const apolloClient = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          getTodos: {
            // Merge paginated results into a single list
            keyArgs: ["filter", "sorting"],
            merge(existing, incoming) {
              return {
                ...incoming,
                edges: [...(existing?.edges ?? []), ...(incoming?.edges ?? [])],
              };
            },
          },
        },
      },
    },
  }),
});

6.1 Apollo Provider

Wrap your root layout in the Apollo provider:

// apps/web/src/app/providers.tsx
'use client';

// Apollo Client v4: ApolloProvider moved from '@apollo/client' to '@apollo/client/react'
import { ApolloProvider } from '@apollo/client/react';
import { apolloClient } from '../lib/apollo-client';

export function Providers({ children }: { children: React.ReactNode }) {
  return <ApolloProvider client={apolloClient}>{children}</ApolloProvider>;
}
// apps/web/src/app/layout.tsx
// Wrap the existing body content — do not replace fonts/metadata already there
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

7. GraphQL Operations (Frontend)

Define your queries and mutations as typed constants. With GraphQL Code Generator (configured below), these generate TypeScript types automatically.

// apps/web/src/graphql/todo.operations.ts
import { gql } from "@apollo/client";

export const GET_TODOS = gql`
  query GetTodos(
    $filter: TodoFilter
    $paging: CursorPaging
    $sorting: [TodoSort!]
  ) {
    getTodos(filter: $filter, paging: $paging, sorting: $sorting) {
      totalCount
      edges {
        cursor
        node {
          id
          text
          isChecked
          status
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

// Part 08: userId removed from CreateTodoInput (injected from JWT instead)
export const CREATE_TODO = gql`
  mutation CreateTodo($input: CreateTodoInput!) {
    createTodo(input: $input) {
      id
      text
      isChecked
      status
      createdAt
    }
  }
`;

export const UPDATE_TODO = gql`
  mutation UpdateTodo($id: Int!, $input: UpdateTodoInput!) {
    updateTodo(id: $id, input: $input) {
      id
      text
      isChecked
      status
      updatedAt
    }
  }
`;

export const DELETE_TODO = gql`
  mutation DeleteTodo($id: Int!) {
    deleteTodo(id: $id)
  }
`;

8. The Todo List Component

// apps/web/src/components/todo-list.tsx
'use client';

import { useState } from 'react';
// Apollo Client v4: React hooks moved to '@apollo/client/react'
import { useMutation, useQuery } from '@apollo/client/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
  CREATE_TODO,
  DELETE_TODO,
  GET_TODOS,
  UPDATE_TODO,
} from '../graphql/todo.operations';

export function TodoList() {
  const [newTodoText, setNewTodoText] = useState('');

  // Fetch todos with Apollo useQuery
  // Meteor equivalent: useTracker(() => TasksCollection.find().fetch())
  const { data, loading, error } = useQuery(GET_TODOS, {
    variables: {
      filter: { status: { eq: 'ACTIVE' } },
      sorting: [{ field: 'createdAt', direction: 'DESC' }],
      paging: { first: 20 },
    },
    fetchPolicy: 'cache-and-network',
  });

  // Create mutation
  // Meteor equivalent: Meteor.callAsync('createTask', text)
  const [createTodo, { loading: creating }] = useMutation(CREATE_TODO, {
    refetchQueries: ['GetTodos'],  // refetch all active GetTodos queries after mutation
    onError: (err) => console.error('Create failed:', err.message),
  });

  // Toggle completion
  const [updateTodo] = useMutation(UPDATE_TODO, {
    refetchQueries: ['GetTodos'],
  });

  // Delete
  const [deleteTodo] = useMutation(DELETE_TODO, {
    refetchQueries: ['GetTodos'],
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!newTodoText.trim()) return;
    // Part 08: userId comes from JWT — hardcoded to 1 for Part 07 demo
    await createTodo({ variables: { input: { text: newTodoText.trim(), userId: 1 } } });
    setNewTodoText('');
  };

  const handleToggle = async (id: number, isChecked: boolean) => {
    await updateTodo({ variables: { id, input: { isChecked: !isChecked } } });
  };

  const handleDelete = async (id: number) => {
    await deleteTodo({ variables: { id } });
  };

  const todos = data?.getTodos?.edges?.map((edge: any) => edge.node) ?? [];
  const totalCount = data?.getTodos?.totalCount ?? 0;

  if (loading && !data) return <div className="text-center p-8">Loading...</div>;
  if (error) return <div className="text-center p-8 text-red-500">Error: {error.message}</div>;

  return (
    <Card className="w-full max-w-md mx-auto shadow-lg">
      <CardHeader>
        <CardTitle className="text-2xl font-bold text-center">
          Enterprise Todo
        </CardTitle>
        <p className="text-sm text-center text-muted-foreground">
          {totalCount} todo{totalCount !== 1 ? 's' : ''}
        </p>
      </CardHeader>

      <CardContent className="space-y-4">
        {/* Add new todo form */}
        <form onSubmit={handleSubmit} className="flex gap-2">
          <Input
            value={newTodoText}
            onChange={(e) => setNewTodoText(e.target.value)}
            placeholder="What needs to be done?"
            disabled={creating}
            className="flex-1"
          />
          <Button type="submit" disabled={creating || !newTodoText.trim()}>
            {creating ? 'Adding...' : 'Add'}
          </Button>
        </form>

        {/* Todo list */}
        <div className="space-y-2">
          {todos.length === 0 && (
            <p className="text-center text-muted-foreground py-4">
              No todos yet. Add one above.
            </p>
          )}

          {todos.map((todo: any) => (
            <div
              key={todo.id}
              className="flex items-center gap-3 p-3 rounded-lg border bg-card"
            >
              {/* Checkbox — toggles isChecked via mutation */}
              <Checkbox
                id={`todo-${todo.id}`}
                checked={todo.isChecked}
                onCheckedChange={() => handleToggle(todo.id, todo.isChecked)}
              />

              {/* Todo text */}
              <label
                htmlFor={`todo-${todo.id}`}
                className={`flex-1 text-sm cursor-pointer ${
                  todo.isChecked ? 'line-through text-muted-foreground' : ''
                }`}
              >
                {todo.text}
              </label>

              {/* Status badge */}
              <Badge variant={todo.isChecked ? 'secondary' : 'default'}>
                {todo.isChecked ? 'Done' : 'Active'}
              </Badge>

              {/* Delete button */}
              <Button
                variant="ghost"
                size="sm"
                onClick={() => handleDelete(todo.id)}
                className="text-destructive hover:text-destructive"
              >
                ×
              </Button>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

8.1 Main Page

// apps/web/src/app/page.tsx
import { TodoList } from '../components/todo-list';

export default function HomePage() {
  return (
    <main className="min-h-screen bg-background p-8">
      <TodoList />
    </main>
  );
}

9. GraphQL Code Generation (Type Safety)

TypeScript types for your GraphQL operations are generated automatically from the schema.

Create codegen.ts at the workspace root:

// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "http://localhost:3333/graphql", // fetch schema from running server
  documents: ["apps/web/src/**/*.ts", "apps/web/src/**/*.tsx"],
  generates: {
    "apps/web/src/graphql/generated.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-react-apollo",
      ],
      config: {
        withHooks: true, // generate useQuery/useMutation hooks
        withComponent: false,
        withHOC: false,
      },
    },
  },
};

export default config;

Add scripts to the root package.json:

{
  "scripts": {
    "web:dev": "nx dev web",
    "codegen": "graphql-codegen --config codegen.ts"
  }
}

Run code generation (with the API running):

yarn codegen

This generates apps/web/src/graphql/generated.ts with:

Then import from generated:

// Before code generation:
const { data } = useQuery(GET_TODOS);
data?.getTodos?.edges?.[0]?.node?.text; // TypeScript: any

// After code generation:
import { useGetTodosQuery } from '../graphql/generated';
const { data } = useGetTodosQuery({ variables: { ... } });
data?.getTodos?.edges?.[0]?.node?.text; // TypeScript: string ← fully typed!

10. Running the Full Stack

Start both the backend and frontend:

# Terminal 1: start the NestJS backend
yarn api:dev

# Terminal 2: start the Next.js frontend (from workspace root)
# Note: @nx/next registers the target as "dev", not "serve"
yarn web:dev

Open:

GraphQL Playground Smoke Test

No auth header needed for Part 07 — all endpoints are public. Open http://localhost:3333/graphql and run each step in order. Note the id returned in step 1 and use it in steps 3–4.

Step 1 — Create (verifies mutation + input validation + service business rule)

mutation {
  createTodo(input: { text: "Buy milk", userId: 1 }) {
    id
    text
    isChecked
    status
    createdAt
  }
}

Expected: { id: 1, text: "Buy milk", isChecked: false, status: "ACTIVE", createdAt: "..." }


Step 2 — Query list (verifies cursor pagination, ConnectionType, totalCount)

query {
  getTodos(
    sorting: [{ field: createdAt, direction: DESC }]
    paging: { first: 10 }
  ) {
    totalCount
    edges {
      node {
        id
        text
        isChecked
        status
        createdAt
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Expected: totalCount: 1, one edge with the todo from step 1, hasNextPage: false.


Step 3 — Update (verifies mutation + FilterQueryBuilder ownership filter)

mutation {
  updateTodo(id: 1, input: { isChecked: true }) {
    id
    isChecked
    updatedAt
  }
}

Expected: { id: 1, isChecked: true, updatedAt: "..." } (timestamp newer than createdAt).


Step 4 — Delete (verifies Boolean scalar mutation)

mutation DeleteTodo {
  deleteTodo(id: 1)
}

Expected: { "data": { "deleteTodo": true } }

Apollo Sandbox quirk: deleteTodo returns Boolean (a scalar, not an object). Apollo Studio Sandbox sometimes rejects anonymous scalar-return mutations with a spurious “syntax error: invalid number” error. Naming the operation (mutation DeleteTodo { ... }) fixes it. If the Sandbox still misbehaves, verify directly with curl:

curl -s -X POST http://localhost:3333/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation { deleteTodo(id: 1) }"}'
# → {"data":{"deleteTodo":true}}

Step 5 — Filter (verifies @FilterableField wiring and FilterQueryBuilder SQL translation)

Re-create a todo first (the one from step 1 was deleted), then:

mutation {
  createTodo(input: { text: "Filter test todo", userId: 1 }) {
    id
    status
  }
}
query {
  getTodos(
    filter: { status: { eq: ACTIVE } }
    sorting: [{ field: createdAt, direction: DESC }]
    paging: { first: 10 }
  ) {
    totalCount
    edges {
      node {
        id
        text
        status
      }
    }
  }
}

Expected: only todos with status: ACTIVE are returned. If you also create one with status: ARCHIVED, it should be excluded.


All five steps passing means the resolver, DTOs, FilterQueryBuilder, cursor pagination, and ConnectionType are all correctly wired. The backend is ready for the Next.js frontend.

What Part 08 changes here: createTodo will drop userId from the input (injected from JWT), and getTodos will only return the authenticated user’s todos. The Playground will require an Authorization: Bearer <token> header.


11. Architecture Review

Here is how the frontend and backend communicate:

┌────────────────────────────────────────────┐
│  Next.js (apps/web, :3000)                 │
│                                            │
│  Apollo useQuery(GET_TODOS)                │
│    └── POST http://localhost:3333/graphql  │
│         { query: "...", variables: {...} } │
└────────────────────────────────────────────┘
              ↕ HTTP/GraphQL
┌────────────────────────────────────────────┐
│  NestJS (apps/api, :3333)                  │
│                                            │
│  Apollo Server                             │
│  └── TodoResolver.getTodos()               │
│       └── QueryBus → Handler → Service     │
│            └── TypeORM → PostgreSQL        │
└────────────────────────────────────────────┘

There is no DDP. There is no Minimongo. There is no reactive data sync. The frontend explicitly fetches data when it needs it (useQuery) and explicitly triggers writes (useMutation). Apollo Client caches results and refetchQueries triggers re-fetches after mutations.

This is less magical than Meteor’s reactive subscriptions — and far more predictable, scalable, and debuggable.


Quick Reference

ConceptAnalogyMeteor equivalentThe one rule
@ObjectType / AbstractDtoStandard response envelopeMinimongo document shapeOnly @Field / @FilterableField properties are visible to clients
@InputType + class-validatorIntake form with printed rulescheck(input, String) — but optionalNever accept id, tenantId, userId, createdAt from the client
@FieldListed on the menuAny document propertyClients can read it but cannot filter by it
@FilterableFieldMenu item with a filter toggleMinimongo query keyOnly expose on columns you explicitly support filtering on
QueryArgsType + ConnectionTypeAuto-generated filter/sort/page formfind() cursor with sort/limitOne @ArgsType declaration gives you filtering, sorting, and cursor pagination
Cursor pagination (PagingStrategies.CURSOR)Jump to bookmark (constant cost)No equivalent — Meteor used offsetUse cursor pagination for all production list queries
ResolverReceptionist + personal shopperMeteor.methods entry (routing only)Zero business logic; dispatches to CommandBus/QueryBus
FilterQueryBuilderSmart search deskMinimongo query operatorsTranslates GraphQL filters to TypeORM SQL; merges @Authorize automatically

Summary

Backend GraphQL:

ConceptDecorator/ToolMeteor equivalent
Return type@ObjectType()Minimongo document shape
Base output typeAbstractDtoNo equivalent
Input type@InputType()Method argument
Input validationclass-validator decoratorscheck(input, String) — optional
Exposed field@Field()Any document property
Filterable field@FilterableField()Minimongo query key
List query with paginationQueryArgsType + ConnectionTypefind() cursor
Cursor paginationPagingStrategies.CURSORNo equivalent
SQL filter translationFilterQueryBuilderMinimongo’s query operators
Auth guard (Part 08)@UseGuards(AuthJwtGuard)Meteor.userId() check

Frontend:

ConceptToolMeteor equivalent
Data fetchinguseQuery(GET_TODOS)useTracker(() => Collection.find())
MutationsuseMutation(CREATE_TODO)Meteor.callAsync('method', data)
Client cacheApollo InMemoryCacheMinimongo
Type safetyGraphQL Code GeneratorNone (Meteor was untyped)
UI componentsShadcn UI + TailwindPicoCSS global styles

Edit page
Share this post:

Next Post
Authentication, Guards & Security Patterns
Previous Post
CQRS - The Enterprise Request Pipeline