Appearance
Demo: Posts CRUD
Route: /demo/posts · Feature module: src/features/demo/posts/
What this demonstrates
The canonical full CRUD reference implementation against a real Prisma-backed model - not mock data, unlike the customers demo. This is the module to actually copy when building your first real feature (see Adding a new feature). It also holds the reference example of gating a tRPC procedure behind an active Polar subscription.
Key files
| File | Role |
|---|---|
schema/post-schema.ts | createPostSchema, updatePostSchema |
server/router.ts | Full CRUD router + one premium-gated procedure |
hooks/use-posts.ts | Query + mutation hooks, cache invalidation |
components/posts-container.tsx | All four UI states (loading/empty/error/content) |
components/post-item.tsx | Delete via ConfirmDialog |
components/post-create-dialog.tsx | Create form |
How it works
Ownership checks, by hand, every time.
postsRouterexposesgetMany,getOne,create,update,delete- allprotectedProcedure. Prisma has no per-request row-level security, sogetOne,update, anddeleteeach explicitly checkpost.userId === ctx.auth.user.idbefore returning or mutating anything, throwingNOT_FOUNDotherwise:tsconst post = await prisma.post.findUnique({ where: { id: input.id } }) if (!post || post.userId !== ctx.auth.user.id) { throw new TRPCError({ code: "NOT_FOUND" }) }Cache invalidation, the simple way. Every mutation hook in
use-posts.tsfollows the same shape on success: show a toast, thenqueryClient.invalidateQueries(trpc.posts.getMany.queryOptions()). Rather than hand-patching the cached list after a create/update/delete, it just refetches - the correct default until you have a specific reason (e.g. a very expensive query) to optimize further.All four UI states, for real.
PostsContainerimplements the loading/empty/error/content rule from Frontend directly: a skeleton list whileisLoading, an inline error block onisError, an empty-state block with a create CTA when the list is empty, and the populated list otherwise - nothing here only handles the happy path.Delete confirmation.
PostItem's delete button never deletes on click - it opensConfirmDialog, wires the dialog'sisLoadingto the delete mutation'sisPending, and closes the dialog from the mutation'sonSuccess. This is the exact pattern documented in Frontend - Confirm dialogs.Premium gating, unwired on purpose.
bulkPublishis apremiumProcedurethat flips every unpublished post owned by the current user to published:tsbulkPublish: premiumProcedure.mutation(async ({ ctx }) => { return prisma.post.updateMany({ where: { userId: ctx.auth.user.id, published: false }, data: { published: true }, }) }),There's deliberately no button for it in the UI - it exists purely as a copy-paste reference for gating a procedure behind an active subscription. Call it directly (e.g. from a tRPC panel) to see a
FORBIDDENerror without an active subscription, or note it succeeds for everyone when Polar isn't configured at all (mock mode - see Auth & Billing).
Try it
Open /demo/posts, create a post, then delete one and note the confirm dialog. Refresh the page - unlike the other demos, this one actually persists to Postgres.
Related docs
- Features - Adding a new feature - this demo is the template that walkthrough is built from
- Auth & Billing - Premium feature gating - the
premiumProcedurepattern used bybulkPublish