place

Getting started

Six steps from zero to a running place app — install, add a page, add typed data, add a server action, add an interactive island, add a capability. The whole flow takes about four minutes. If you've used Next or TanStack Start you'll recognize the shape; the difference is in what's missing: no file-system routing, no codegen, no encrypted action IDs, no 'use client' markers, no per-page hydration bundle.

1. Install

place runs on Bun. If you don't have it yet:

bash
curl -fsSL https://bun.sh/install | bash

Then scaffold a fresh app. The scaffolder is interactive by default — pick a template (minimal / content / app), then check the features you want — but every choice is also exposed as a flag so CI and one-liners work cleanly.

bash
# Interactive (recommended): pick template + features in the prompt bunx @place-ts/create-app my-app # Or skip prompts with flags: bunx @place-ts/create-app my-app --template content --with tests bunx @place-ts/create-app . --template app --with tests --with ci bunx @place-ts/create-app --list # show templates + features # After scaffold: cd my-app bun dev # port 5174 (auto-walks if busy)

Templates and features are composable: layer any feature on any template. The picker shows each template's default-on features as pre-checked so most users press enter through it.

text
Templates: minimal barebones — home + about pages, your design system content blog · docs · wiki — @place-ts/data posts + @place-ts/search palette app interactive SaaS — @place-ts/persistence + design system Features (combine freely with any template): theme-toggle <ThemeToggle/> (System · Light · Dark) — default ON tests vitest + sample test ci GitHub Actions workflow design-system @place-ts/design imports persistence @place-ts/persistence localStorage adapter

After scaffolding, bun dev starts the server on localhost:5174 with hot-reload, source-map error overlay, and auto-Tailwind. Open http://localhost:5174 in a browser — you'll see the template's home page running before you've written any code. The framework auto-walks ports if 5174 is busy — no setup, no config files. bun run build pre-renders the app to dist/ as a static site with hashed assets and a Cloudflare-shape _headers file ready to deploy.

Want to dial back log noise? Set PLACE_LOG_LEVEL=warn. Want more? Set PLACE_LOG_LEVEL=debug — surfaces static-asset requests, per-route table, the view-classifier report.

The theme-toggle feature (default-on in every template) drops a working System · Light · Dark picker into the header — it's a single <ThemeToggle /> from @place-ts/design. Want to customize it? Tweak props (variant="cycle", custom labels/icons), drop one tier to useTheme() for BYO UI, or replace it entirely with setTheme('dark') calls. See Theming & dark mode for the four-tier ladder.

2. Add a page

Pages are values. Each one declares its own path, view, optional load, optional actions. The framework derives the routes table from the pages array — the path is written exactly once, where the page lives.

src/pages/about.page.tsxts
// src/pages/about.page.tsx import { page } from '@place-ts/component' export default page('/about', { meta: 'About', // string shorthand; layout's titleTemplate adds the suffix view: () => ( <article> <h1>About</h1> <p>Hi from place.</p> </article> ), })

Add it to the app's pages array. Refactoring? Rename the import — TypeScript catches every call site. No codegen step.

3. Add typed data

Pages can declare a typed search: schema. The framework runs it server-side before view(), so URL params arrive parsed and validated. Use the built-in shape() for flat objects; Zod/Valibot slot in via the ActionSchema interface.

ts
import { page, shape, useSearch } from '@place-ts/component' export default page('/posts', { search: shape({ page: 'number', tag: 'string?' }), view: (props) => { const { page: p, tag } = useSearch<{ page: number; tag?: string }>(props) return <PostList page={p} tag={tag} /> }, })

4. Add an action

Co-located actions live in the page's on: dict. Each handler auto-registers at POST {path}/_action/{key} with the full security pipeline: auto-CSRF, same-origin enforcement, body-size limit, prototype-pollution guard. The client-side caller is auto-typed.

ts
const postPage = page('/posts/:id', { on: { delete: async (_input, { params }) => { await db.posts.delete(params.id) return { ok: true } }, }, view: () => ( <button onClick={() => postPage.delete()}>Delete</button> ), })

No 'use server' marker, no Babel pass, no encrypted action IDs. The endpoint is visible in your routes table.

5. Add interactivity (an island)

Anything that needs JS in the browser — a click handler, reactive state, a timer — goes inside an island(import.meta.url, fn) call. The wrapper makes the function a JSX-callable component; the framework's Bun plugin discovers it at build time, bundles it per-island, and inlines a <script> into pages that actually render the island.

ts
// src/islands/counter.tsx — `island`, `state` auto-imported. const Counter = island(import.meta.url, (props: { start?: number }) => { const n = state(props.start ?? 0) return ( <button onClick={() => n.set(n() + 1)}> Clicked {n} times </button> ) }) // Use anywhere in a page (or another island), JSX-callable. export default page('/demo', { view: () => ( <article> <h1>Click me</h1> <Counter start={5} /> </article> ), })

Pages with no islands ship zero framework JS. Pages with one island ship ~1–2 KB gzipped for the island plus a 14 KB shared runtime chunk that's cached across every interactive page in your app. The hydration boundary is typed — no 'use client' marker, no string convention. See island, Tabs, Show, Suspense for the full primitive list.

6. Add a capability

Capabilities replace React-style context. Typed slots, lexical scope, no action-at-a-distance. Browser-only caps (e.g. a path router that drives window.history) declare themselves with { clientOnly: true }, and the component framework auto-emits an SSR-safe placeholder when one is touched during render.

ts
import { defineCapability } from '@place-ts/capability' interface NoteStore { all(): readonly Note[] create(input: NoteInput): string } export const NoteStoreCap = defineCapability<NoteStore>('NoteStore', { clientOnly: true, })

Then install it in your app config:

src/app.tsts
app({ pages: [home, postIndex, postDetail], caps: [ [RouterCap, pathRouter], [NoteStoreCap, { server: () => inMemoryStore(seed), // SSR-friendly seed client: () => localStorageStore(), // hydrates to real data }], ], }).start() // binds Bun.serve, prints the local URL

Next steps

  • API reference for page()
  • Roadmap — shipped milestones and what's next.
  • Examples: examples/commonplace is a real reference app. Every shipping feature is exercised end-to-end.