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:

bash
bunx @place/create-app my-app cd my-app bun install bun run dev

The dev server starts on localhost:5174 with hot-reload, source-map error overlay, and auto-Tailwind. No vite.config.ts, no next.config.js, no tsup.

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/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/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/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 }], ], }).run()

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.