How to Implement Prompt Collections and Pinned Prompts in prompts.chat
Implementing prompt collections and pinned prompts in prompts.chat requires Prisma models with composite unique keys, Next.js API routes for CRUD operations, and React components that enforce business rules like the 3-pin limit and ownership validation.
The open-source prompts.chat platform enables users to curate AI prompts through personal collections and profile pins. This guide examines the complete implementation of these bookmarking features using the Next.js App Router, Prisma ORM, and TypeScript authentication layer as found in the f/prompts.chat repository.
Database Schema and Prisma Models
Both features rely on composite unique constraints to prevent duplicate entries. The schema defines explicit relations between users and prompts through join tables.
model Collection {
id String @id @default(cuid())
userId String
promptId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
prompt Prompt @relation(fields: [promptId], references: [id])
@@unique([userId, promptId]) // prevents duplicate collections
}
model PinnedPrompt {
id String @id @default(cuid())
userId String
promptId String
order Int // for manual sort order (max 3 items)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
prompt Prompt @relation(fields: [promptId], references: [id])
@@unique([userId, promptId])
}
The @@unique([userId, promptId]) attribute on both models enables findUnique lookups for idempotent add/remove operations and guarantees data integrity at the database level.
Implementing Prompt Collections
Prompt collections allow authenticated users to save any public prompt—or their own private prompts—to a personal list for later access.
Collection API Endpoints
The src/app/api/collection/route.ts file implements a RESTful handler supporting three methods:
GET - Retrieves the logged-in user's collection with full prompt metadata:
const collections = await db.collection.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
include: {
prompt: {
include: { author, category, tags, _count: { select: { votes: true } } }
}
},
});
POST - Adds a prompt after validation:
- Validates the session via
auth()fromsrc/lib/auth/index.ts - Parses
promptIdusing Zod schema validation - Guards against duplicates and missing prompts
- Prevents adding private prompts unless the user owns them
- Creates the record with
db.collection.create
DELETE - Removes an item using the composite key:
await db.collection.delete({
where: {
userId_promptId: {
userId: session.user.id,
promptId: promptId,
},
},
});
Add-to-Collection Button Component
The client-side interaction lives in src/components/prompts/add-to-collection-button.tsx. This component toggles between adding and removing items based on local state:
<Button
variant={inCollection ? "secondary" : "outline"}
onClick={handleClick}
disabled={isLoading}
>
{isLoading ? <Loader2 /> : inCollection ? <Check /> : <Bookmark />}
{inCollection ? t("inCollection") : t("addToCollection")}
</Button>
The handleClick function conditionally calls POST or DELETE on /api/collection, then fires analyticsCollection.add or analyticsCollection.remove events upon success. The component receives isLoggedIn from the server session to gate the functionality.
Collection Page
The saved prompts list renders server-side in src/app/collection/page.tsx. It queries the database for the current user's collections, maps the results to a PromptList component, and displays an empty state with a navigation link when no items exist.
Implementing Pinned Prompts
Pinned prompts allow users to feature up to three of their own prompts prominently on their profile. This feature enforces strict ownership and quantity limits.
Pin API Endpoints and Business Logic
The dynamic route src/app/api/prompts/[id]/pin/route.ts handles pinning operations with custom guards:
POST - Creates a pinned prompt with validation:
// 1. Verify ownership
if (prompt.authorId !== session.user.id) {
return new Response("Unauthorized", { status: 401 });
}
// 2. Enforce the 3-pin limit
const currentPins = await db.pinnedPrompt.count({
where: { userId: session.user.id },
});
if (currentPins >= MAX_PINNED_PROMPTS) {
return new Response("Max pinned prompts reached", { status: 400 });
}
// 3. Compute next sort order
const maxOrder = await db.pinnedPrompt.aggregate({
where: { userId: session.user.id },
_max: { order: true },
});
const nextOrder = (maxOrder._max.order ?? 0) + 1;
// 4. Create the pin
await db.pinnedPrompt.create({
data: { userId: session.user.id, promptId, order: nextOrder },
});
DELETE - Unpins a prompt using deleteMany filtered by userId and promptId, ensuring users can only remove their own pins.
Pin Analytics Tracking
User interactions are tracked in src/lib/analytics.ts via the analyticsPrompt object:
pin: (promptId: string) => {
trackEvent({ action: "pin_prompt", category: "prompt", prompt_id: promptId });
},
unpin: (promptId: string) => {
trackEvent({ action: "unpin_prompt", category: "prompt", prompt_id: promptId });
},
These functions feed Google Analytics 4 events when the UI invokes them after successful API responses.
End-to-End Integration Examples
To add collection functionality to a prompt detail view:
import { AddToCollectionButton } from "@/components/prompts/add-to-collection-button";
export default function PromptDetail({ prompt, session }) {
return (
<div>
<h1>{prompt.title}</h1>
<AddToCollectionButton
promptId={prompt.id}
initialInCollection={prompt.inCollection}
isLoggedIn={!!session?.user}
/>
</div>
);
}
To implement a pin toggle from the UI:
import { analyticsPrompt } from "@/lib/analytics";
async function togglePin(promptId: string, isPinned: boolean) {
const method = isPinned ? "DELETE" : "POST";
const res = await fetch(`/api/prompts/${promptId}/pin`, { method });
if (res.ok) {
isPinned ? analyticsPrompt.unpin(promptId) : analyticsPrompt.pin(promptId);
// Update local UI state...
}
}
Summary
Implementing these curation features in prompts.chat demonstrates a consistent pattern for user-generated content platforms:
- Composite unique keys in Prisma prevent duplicate collection entries and duplicate pins at the database level
- API route guards enforce business logic such as the 3-pin maximum, ownership verification, and private prompt restrictions
- Zod validation ensures type-safe request handling before database operations
- Server components render initial lists while client components handle interactive add/remove actions
- Analytics integration tracks user engagement through the
src/lib/analytics.tsevent helpers
Frequently Asked Questions
How does prompts.chat prevent users from pinning more than three prompts?
The API route at src/app/api/prompts/[id]/pin/route.ts enforces the limit by querying the current pin count with db.pinnedPrompt.count before creating a new record. If the count exceeds MAX_PINNED_PROMPTS (set to 3), the endpoint returns a 400 status code, preventing the database mutation entirely.
Can users add private prompts from other authors to their collections?
No. The POST handler in src/app/api/collection/route.ts explicitly checks prompt visibility. If a prompt is private and the requesting user's ID does not match the prompt's authorId, the server returns a 403 Forbidden response. Users may only collect their own private prompts or any public prompt.
What database constraint prevents duplicate collection entries?
The Prisma schema defines @@unique([userId, promptId]) on the Collection model. This composite unique index allows the API to use findUnique for lookups and guarantees that a user cannot add the same prompt twice. The DELETE handler leverages this same composite key for precise, race-condition-safe removals.
How is the sort order determined for pinned prompts?
When pinning a prompt via POST, the API calculates the next order value by aggregating the maximum existing order for that user's pins using db.pinnedPrompt.aggregate({ _max: { order: true } }). It increments this value by 1, ensuring new pins appear last in the sequence. The order field is an integer on the PinnedPrompt model that supports future drag-and-drop reordering functionality.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →