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:
curl -fsSL https://bun.sh/install | bash
Then scaffold a fresh app:
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.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.
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.
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.
// 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.
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:
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/commonplaceis a real reference app. Every shipping feature is exercised end-to-end.