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:
- DTOs:
@ObjectType(what GraphQL returns),@InputType(what clients send) @Fieldvs@FilterableField— and the security implicationsclass-validatordecorators for input validation- The resolver anatomy:
@Query,@Mutation,@Args QueryArgsTypeandConnectionType— automatic filtering, sorting, and cursor paginationFilterQueryBuilder— hownestjs-querybridges GraphQL filters to SQL
Frontend:
- Setting up Next.js 16 with the App Router in the Nx workspace
- Shadcn UI initialization
- Apollo Client setup
- Writing GraphQL queries and mutations
- Building the todo list component with real data
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:
- The entity is the DB schema — it has all columns, FKs, internal fields
- The DTO is the API shape — it exposes only what clients should see
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(fromnestjs-dev-utilities) is the base class all output DTOs extend. It exposesid,createdAt, andupdatedAtas@Field()automatically. IfAbstractEntityis the company letterhead for database rows,AbstractDtois 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.
@ObjectTypewithAbstractDtoenforces 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:
| Decorator | What it does | When 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
@FilterableFieldto columns you explicitly support filtering on. Never add it to internal columns likepassword,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-validatordecorators on an@InputTypecombined with the globalValidationPipe({ 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:
userIdis a@Field()for now — Part 08 fixes this.In production, exposing
userIdas a client-supplied field is a security risk: a malicious client could senduserId: 999and create todos that appear to belong to another user. Part 08 adds JWT authentication — at that pointuserIdis removed from@Field()and injected server-side from the verified token instead. For this part, the endpoints are public anduserIdis 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:
filter: TodoFilter— filter by any@FilterableField(nested AND/OR, operators: eq, like, in, between, gt, lt…)sorting: [TodoSort!]— sort by any@FilterableField, ASC or DESCpaging: CursorPaging— cursor-based pagination (first, after, last, before)
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.QueryArgsTypegenerates a full GraphQL filter/sort/paging argument schema from your DTO’s@FilterableFielddeclarations — 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()withskipandlimitis offset pagination — it scans and discards rows. There was no built-in cursor pagination.PagingStrategies.CURSORuses 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, andMeteor.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 explicituserIdinput 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.FilterQueryBuildertranslates 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/clientwithout a version pin installs v4 +graphql@17, which requires Node 22+. This project runs Node 20 — pin tographql@16as shown above. Same applies to@graphql-codegen/cli: v6 pulls inlistr2@10which also requires Node 22 — pin to@5.
5.3 Initialize Shadcn UI (Nx Monorepo)
Nx gotcha:
apps/webhas nopackage.json— deps are managed at the workspace root. Runningnpx shadcn initdirectly fromapps/webdetects nopackage.jsonand 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:
- TypeScript types for every GraphQL type
- Typed
useGetTodosQuery(),useCreateTodoMutation()hooks - Full autocomplete in your IDE
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:
http://localhost:3333/graphql— GraphQL Playground (backend)http://localhost:3000— Next.js app (frontend)
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:
deleteTodoreturnsBoolean(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:
createTodowill dropuserIdfrom the input (injected from JWT), andgetTodoswill only return the authenticated user’s todos. The Playground will require anAuthorization: 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
| Concept | Analogy | Meteor equivalent | The one rule |
|---|---|---|---|
@ObjectType / AbstractDto | Standard response envelope | Minimongo document shape | Only @Field / @FilterableField properties are visible to clients |
@InputType + class-validator | Intake form with printed rules | check(input, String) — but optional | Never accept id, tenantId, userId, createdAt from the client |
@Field | Listed on the menu | Any document property | Clients can read it but cannot filter by it |
@FilterableField | Menu item with a filter toggle | Minimongo query key | Only expose on columns you explicitly support filtering on |
QueryArgsType + ConnectionType | Auto-generated filter/sort/page form | find() cursor with sort/limit | One @ArgsType declaration gives you filtering, sorting, and cursor pagination |
Cursor pagination (PagingStrategies.CURSOR) | Jump to bookmark (constant cost) | No equivalent — Meteor used offset | Use cursor pagination for all production list queries |
| Resolver | Receptionist + personal shopper | Meteor.methods entry (routing only) | Zero business logic; dispatches to CommandBus/QueryBus |
FilterQueryBuilder | Smart search desk | Minimongo query operators | Translates GraphQL filters to TypeORM SQL; merges @Authorize automatically |
Summary
Backend GraphQL:
| Concept | Decorator/Tool | Meteor equivalent |
|---|---|---|
| Return type | @ObjectType() | Minimongo document shape |
| Base output type | AbstractDto | No equivalent |
| Input type | @InputType() | Method argument |
| Input validation | class-validator decorators | check(input, String) — optional |
| Exposed field | @Field() | Any document property |
| Filterable field | @FilterableField() | Minimongo query key |
| List query with pagination | QueryArgsType + ConnectionType | find() cursor |
| Cursor pagination | PagingStrategies.CURSOR | No equivalent |
| SQL filter translation | FilterQueryBuilder | Minimongo’s query operators |
| Auth guard (Part 08) | @UseGuards(AuthJwtGuard) | Meteor.userId() check |
Frontend:
| Concept | Tool | Meteor equivalent |
|---|---|---|
| Data fetching | useQuery(GET_TODOS) | useTracker(() => Collection.find()) |
| Mutations | useMutation(CREATE_TODO) | Meteor.callAsync('method', data) |
| Client cache | Apollo InMemoryCache | Minimongo |
| Type safety | GraphQL Code Generator | None (Meteor was untyped) |
| UI components | Shadcn UI + Tailwind | PicoCSS global styles |