TanStack Barbershop
End-to-end tour of the barbershop booking app - collections, routes, jobs, admin, and frontend.
The barbershop example is a complete booking app built with TanStack Start and QUESTPIE. It demonstrates every major feature of the framework.
Source: examples/tanstack-barbershop/
Project Structure
Key Patterns
Runtime Config
import { runtimeConfig } from "questpie/app";
import { pgBossAdapter } from "questpie/adapters/pg-boss";
import { SmtpAdapter } from "questpie/adapters/smtp";
export default runtimeConfig({
app: { url: process.env.APP_URL || "http://localhost:3000" },
db: { url: process.env.DATABASE_URL || "postgres://localhost/barbershop" },
storage: { basePath: "/api" },
secret: process.env.BETTER_AUTH_SECRET || "demo-secret",
queue: { adapter: pgBossAdapter({ connectionString: DATABASE_URL }) },
});Collection with Hooks
export const appointments = collection("appointments")
.fields(({ f }) => ({
customer: f.relation("user").required(),
barber: f.relation("barbers").required(),
service: f.relation("services").required(),
scheduledAt: f.datetime().required(),
status: f
.select([
{ value: "pending", label: { en: "Pending" } },
{ value: "confirmed", label: { en: "Confirmed" } },
{ value: "completed", label: { en: "Completed" } },
{ value: "cancelled", label: { en: "Cancelled" } },
])
.required()
.default("pending"),
cancellationReason: f.textarea(),
displayTitle: f.text()
.virtual(sql`(SELECT name FROM "user" WHERE id = appointments.customer)
|| ' - ' || TO_CHAR(appointments."scheduledAt", 'YYYY-MM-DD HH24:MI')`),
}))
.form(({ v, f }) =>
v.collectionForm({
fields: [
{
type: "section",
fields: [
f.cancelledAt,
{
field: f.cancellationReason,
hidden: ({ data }) => data.status !== "cancelled",
},
],
},
],
}),
)
.hooks({
afterChange: async ({ data, operation, queue }) => {
if (operation === "create") {
await queue.sendAppointmentConfirmation.publish({
appointmentId: data.id,
customerId: data.customer,
});
}
},
});Service Injection
import { service } from "questpie/services";
export default service({
lifecycle: "singleton",
create: () => ({
computeReadingTime(content: string): number {
/* ... */
},
generateSlug(title: string): string {
/* ... */
},
extractExcerpt(content: string): string {
/* ... */
},
}),
});Used in hooks via services — no imports:
.hooks({
beforeChange: async ({ data, services }) => {
data.slug = services.blog.generateSlug(data.title);
data.readingTime = services.blog.computeReadingTime(data.content);
},
})Client Setup
import { createClient } from "questpie/client";
import type { AppConfig } from "#questpie";
export const client = createClient<AppConfig>({
baseURL:
typeof window !== "undefined"
? window.location.origin
: process.env.APP_URL,
basePath: "/api",
});Frontend Booking
import { client } from "@/lib/client";
import { useMutation, useQuery } from "@tanstack/react-query";
function BookingWizard() {
const { data: barbers } = useQuery({
queryKey: ["barbers"],
queryFn: () => client.routes.getActiveBarbers({}),
});
const booking = useMutation({
mutationFn: (data) => client.routes.createBooking(data),
onSuccess: (result) => {
// Navigate to confirmation
},
});
// Multi-step wizard UI...
}Pages, Workflow, And Preview
The barbershop page collection demonstrates the standard page-builder setup: blocks for page content, .preview({ url }) for the existing Live Preview button, v.collectionForm() for the normal editor, and workflow stages for publishing.
export const pages = collection("pages")
.fields(({ f }) => ({
title: f.text(255).required().localized(),
slug: f.text(255).required(),
description: f.textarea().localized(),
content: f.blocks().localized(),
}))
.preview({
enabled: true,
position: "right",
defaultWidth: 50,
url: ({ record }) => {
const slug = record.slug as string;
return slug === "home" ? "/?preview=true" : `/${slug}?preview=true`;
},
})
.options({
versioning: {
workflow: {
initialStage: "draft",
stages: {
draft: { transitions: ["review", "published"] },
review: { transitions: ["draft", "published"] },
published: { transitions: ["draft"] },
},
},
},
})
.form(({ v, f }) =>
v.collectionForm({
fields: [
{ type: "section", label: "Page", fields: [f.title, f.description] },
{ type: "section", label: "Content", fields: [f.content] },
],
sidebar: { fields: [f.slug] },
}),
);Public frontend routes read only published page snapshots. Preview requests use the draft-mode cookie set by the preview token flow to load the working stage for the editor.
const page = await app.collections.pages.findOne(
{
where: { slug },
...(draftMode ? {} : { stage: "published" }),
},
ctx,
);The page renderer uses the exported preview APIs and BlockRenderer so custom block renderers can annotate fields with PreviewField.
const preview = useCollectionPreview({
initialData: page,
onRefresh: () => router.invalidate(),
});
return (
<PreviewProvider preview={preview}>
<PreviewField field="title" editable="text" as="h1">
{preview.data.title}
</PreviewField>
<BlockRenderer
content={preview.data.content}
renderers={admin.blocks}
data={preview.data.content?._data}
selectedBlockId={preview.selectedBlockId}
onBlockClick={
preview.isPreviewMode ? preview.handleBlockClick : undefined
}
/>
</PreviewProvider>
);What This Example Demonstrates
| Feature | Where |
|---|---|
| Complex fields (objects, arrays, select) | collections/barbers.ts |
| Many-to-many relations | barbers ↔ barberServices ↔ services |
| Virtual SQL fields | appointments.ts → displayTitle |
| Lifecycle hooks | appointments.ts → afterChange |
| Service injection | blog.ts → used in blog-posts.ts hooks |
| Background jobs | jobs/send-appointment-confirmation.ts |
| Email templates | emails/appointment-confirmation.ts |
| Server routes | routes/create-booking.ts |
| Admin dashboard | config/admin.ts — stats, charts, timeline |
| Admin sidebar | config/admin.ts — 6 sections, mixed items |
| Content blocks | blocks/hero.ts, pages.ts with f.blocks() |
| Conditional visibility | appointments.ts form → hidden |
| Computed form fields | barbers.ts form → slug from name |
| Multi-language (i18n) | Labels, content, admin UI |
| Access control | site-settings.ts → admin-only update |
| Live preview | pages.ts → Preview button, iframe, field and block annotations |
| Workflow publishing | pages.ts → public reads use stage: "published" |
Related Pages
- First App — Minimal getting started
- Collections — Collection API
- Typed Routes — Typed JSON route handlers
- Dashboard — Dashboard widgets