QUESTPIE

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

Mermaid

Key Patterns

Runtime Config

questpie.config.ts
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

collections/appointments.ts
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

services/blog.ts
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

lib/client.ts
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

routes/_app/booking.tsx
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.

src/questpie/server/collections/pages.ts
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.

src/lib/get-page.function.ts
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.

src/components/pages/page-renderer.tsx
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

FeatureWhere
Complex fields (objects, arrays, select)collections/barbers.ts
Many-to-many relationsbarbers ↔ barberServices ↔ services
Virtual SQL fieldsappointments.tsdisplayTitle
Lifecycle hooksappointments.tsafterChange
Service injectionblog.ts → used in blog-posts.ts hooks
Background jobsjobs/send-appointment-confirmation.ts
Email templatesemails/appointment-confirmation.ts
Server routesroutes/create-booking.ts
Admin dashboardconfig/admin.ts — stats, charts, timeline
Admin sidebarconfig/admin.ts — 6 sections, mixed items
Content blocksblocks/hero.ts, pages.ts with f.blocks()
Conditional visibilityappointments.ts form → hidden
Computed form fieldsbarbers.ts form → slug from name
Multi-language (i18n)Labels, content, admin UI
Access controlsite-settings.ts → admin-only update
Live previewpages.ts → Preview button, iframe, field and block annotations
Workflow publishingpages.ts → public reads use stage: "published"

On this page