| name | presenters |
| description | This skill should be used when transforming entities for API responses or client display. Provides presenter patterns (Rails serializers equivalent). |
| triggers | ["serializer","presenter","API response","JSON format","transform","view model","response format"] |
Presenters Skill
Presenters are the Next.js equivalent of Rails serializers (ActiveModel::Serializer, JBuilder). They transform entities into the format needed for API responses or client consumption.
Location
# Module-specific presenters
modules/<name>/presenters/
├── index.ts
├── <name>-presenter/
│ ├── index.ts
│ ├── types.ts
│ ├── <name>-presenter.ts
│ └── <name>-presenter.test.ts
# Shared presenters (pagination, errors, etc.)
shared/presenters/
├── index.ts
├── pagination-presenter/
├── error-presenter/
└── api-response-presenter/
Basic Presenter Pattern
import type { User } from "@/shared/entities/user";
export interface UserJSON {
id: string;
name: string;
email: string;
avatarUrl: string | null;
role: string;
createdAt: string;
}
export interface UserOptionJSON {
value: string;
label: string;
}
export interface UserWithPostsJSON extends UserJSON {
posts: PostJSON[];
postsCount: number;
}
export interface UserListJSON {
users: UserJSON[];
pagination: PaginationJSON;
}
import type { User, UserWithPosts } from "@/shared/entities/user";
import type { PaginationMeta } from "@/shared/presenters/pagination-presenter";
import type {
UserJSON,
UserListJSON,
UserOptionJSON,
UserWithPostsJSON,
} from "./types";
export const UserPresenter = {
toJSON(user: User): UserJSON {
return {
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: user.role,
createdAt: user.createdAt.toISOString(),
};
},
toOption(user: User): UserOptionJSON {
return {
value: user.id,
label: user.name,
};
},
toList(users: User[], pagination: PaginationMeta): UserListJSON {
return {
users: users.map(UserPresenter.toJSON),
pagination: {
page: pagination.page,
pageSize: pagination.pageSize,
total: pagination.total,
totalPages: Math.ceil(pagination.total / pagination.pageSize),
hasNext: pagination.page < Math.ceil(pagination.total / pagination.pageSize),
hasPrev: pagination.page > 1,
},
};
},
toJSONWithPosts(user: UserWithPosts): UserWithPostsJSON {
return {
...UserPresenter.toJSON(user),
posts: user.posts.map(PostPresenter.toJSON),
postsCount: user.posts.length,
};
},
toOptions(users: User[]): UserOptionJSON[] {
return users.map(UserPresenter.toOption);
},
};
Index Export
export { UserPresenter } from "./user-presenter";
export type {
UserJSON,
UserListJSON,
UserOptionJSON,
UserWithPostsJSON,
} from "./types";
Shared Presenters
Pagination Presenter
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
}
export interface PaginationJSON {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
import type { PaginationJSON, PaginationMeta } from "./types";
export const PaginationPresenter = {
toJSON(meta: PaginationMeta): PaginationJSON {
const totalPages = Math.ceil(meta.total / meta.pageSize);
return {
page: meta.page,
pageSize: meta.pageSize,
total: meta.total,
totalPages,
hasNext: meta.page < totalPages,
hasPrev: meta.page > 1,
};
},
getPageNumbers(
currentPage: number,
totalPages: number,
maxVisible: number = 5
): (number | "...")[] {
if (totalPages <= maxVisible) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "...")[] = [];
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, currentPage - half);
let end = Math.min(totalPages, currentPage + half);
if (currentPage <= half) {
end = maxVisible;
} else if (currentPage >= totalPages - half) {
start = totalPages - maxVisible + 1;
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("...");
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push("...");
pages.push(totalPages);
}
return pages;
},
};
Error Presenter
export interface ErrorJSON {
code: string;
message: string;
details?: Record<string, string[]>;
}
export interface ValidationErrorJSON extends ErrorJSON {
code: "VALIDATION_ERROR";
details: Record<string, string[]>;
}
import type { ErrorJSON, ValidationErrorJSON } from "./types";
export const ErrorPresenter = {
toJSON(code: string, message: string): ErrorJSON {
return { code, message };
},
notFound(resource: string): ErrorJSON {
return {
code: "NOT_FOUND",
message: `${resource} not found`,
};
},
unauthorized(message: string = "Unauthorized"): ErrorJSON {
return {
code: "UNAUTHORIZED",
message,
};
},
forbidden(message: string = "Access denied"): ErrorJSON {
return {
code: "FORBIDDEN",
message,
};
},
validation(details: Record<string, string[]>): ValidationErrorJSON {
return {
code: "VALIDATION_ERROR",
message: "Validation failed",
details,
};
},
fromZodError(error: z.ZodError): ValidationErrorJSON {
const details: Record<string, string[]> = {};
for (const issue of error.issues) {
const path = issue.path.join(".");
if (!details[path]) {
details[path] = [];
}
details[path].push(issue.message);
}
return ErrorPresenter.validation(details);
},
internal(message: string = "Internal server error"): ErrorJSON {
return {
code: "INTERNAL_ERROR",
message,
};
},
};
API Response Presenter
import type { ErrorJSON } from "../error-presenter";
export interface ApiSuccessResponse<T> {
success: true;
data: T;
}
export interface ApiErrorResponse {
success: false;
error: ErrorJSON;
}
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
import type { ErrorJSON } from "../error-presenter";
import type { ApiErrorResponse, ApiSuccessResponse } from "./types";
export const ApiResponsePresenter = {
success<T>(data: T): ApiSuccessResponse<T> {
return {
success: true,
data,
};
},
error(error: ErrorJSON): ApiErrorResponse {
return {
success: false,
error,
};
},
};
Usage Examples
In API Handlers
import { Effect } from "effect";
import { UserPresenter } from "@/modules/users/presenters/user-presenter";
import { ApiResponsePresenter } from "@/shared/presenters/api-response-presenter";
import { ErrorPresenter } from "@/shared/presenters/error-presenter";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1");
const pageSize = parseInt(searchParams.get("pageSize") ?? "20");
const result = await Effect.runPromiseExit(
UserService.findPaginated({ page, pageSize })
);
if (result._tag === "Failure") {
return Response.json(
ApiResponsePresenter.error(ErrorPresenter.internal()),
{ status: 500 }
);
}
const { users, total } = result.value;
const response = UserPresenter.toList(users, { page, pageSize, total });
return Response.json(ApiResponsePresenter.success(response));
}
In Server Actions
import { actionClient } from "@/shared/lib/safe-action";
import { z } from "zod";
import { UserPresenter } from "@/modules/users/presenters/user-presenter";
import { UserService } from "@/modules/users/services/user-service";
export const getUserAction = actionClient
.schema(z.object({ id: z.string().uuid() }))
.action(async ({ parsedInput }) => {
const user = await Effect.runPromise(
UserService.findById(parsedInput.id)
);
if (!user) {
return { error: "User not found" };
}
return { data: UserPresenter.toJSON(user) };
});
In Server Components
import { UserPresenter } from "@/modules/users/presenters/user-presenter";
import { UserService } from "@/modules/users/services/user-service";
export async function ScreenUserList() {
const { users, total } = await Effect.runPromise(
UserService.findPaginated({ page: 1, pageSize: 20 })
);
const presentedUsers = UserPresenter.toList(users, {
page: 1,
pageSize: 20,
total,
});
return <UserListContainer users={presentedUsers} />;
}
Advanced Patterns
Conditional Fields
export const UserPresenter = {
toJSON(user: User, options?: { includeEmail?: boolean }): UserJSON {
const base = {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
};
if (options?.includeEmail) {
return { ...base, email: user.email };
}
return base;
},
};
Nested Presenters
import { UserPresenter } from "@/modules/users/presenters/user-presenter";
export const PostPresenter = {
toJSON(post: Post): PostJSON {
return {
id: post.id,
title: post.title,
content: post.content,
createdAt: post.createdAt.toISOString(),
};
},
toJSONWithAuthor(post: PostWithAuthor): PostWithAuthorJSON {
return {
...PostPresenter.toJSON(post),
author: UserPresenter.toJSON(post.author),
};
},
};
Presenter with Computed Fields
export const OrderPresenter = {
toJSON(order: Order): OrderJSON {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const tax = subtotal * 0.07;
const total = subtotal + tax + order.shippingCost;
return {
id: order.id,
status: order.status,
items: order.items.map(OrderItemPresenter.toJSON),
subtotal,
tax,
shippingCost: order.shippingCost,
total,
createdAt: order.createdAt.toISOString(),
};
},
};
Testing Presenters
import { describe, it, expect } from "vitest";
import { UserFactory } from "@/shared/factories/user-factory";
import { UserPresenter } from "./user-presenter";
describe("UserPresenter", () => {
describe("toJSON", () => {
it("should transform user entity to JSON", () => {
const user = UserFactory.build({
id: "123",
name: "John Doe",
email: "john@example.com",
createdAt: new Date("2024-01-01"),
});
const result = UserPresenter.toJSON(user);
expect(result).toEqual({
id: "123",
name: "John Doe",
email: "john@example.com",
avatarUrl: user.avatarUrl,
role: user.role,
createdAt: "2024-01-01T00:00:00.000Z",
});
});
});
describe("toOption", () => {
it("should transform user to select option", () => {
const user = UserFactory.build({ id: "123", name: "John Doe" });
const result = UserPresenter.toOption(user);
expect(result).toEqual({
value: "123",
label: "John Doe",
});
});
});
describe("toList", () => {
it("should transform users with pagination", () => {
const users = UserFactory.buildList(2);
const pagination = { page: 1, pageSize: 10, total: 25 };
const result = UserPresenter.toList(users, pagination);
expect(result.users).toHaveLength(2);
expect(result.pagination).toEqual({
page: 1,
pageSize: 10,
total: 25,
totalPages: 3,
hasNext: true,
hasPrev: false,
});
});
});
});
Rails Serializer Mapping
| Rails Serializer | Next.js Presenter |
|---|
UserSerializer.new(user).as_json | UserPresenter.toJSON(user) |
has_many :posts | PostPresenter.toJSON in nested call |
attribute :full_name { "#{first} #{last}" } | Computed field in presenter |
ActiveModel::Serializer::CollectionSerializer | users.map(UserPresenter.toJSON) |
| Conditional attributes | Options parameter |
| JBuilder templates | Presenter methods |