Why place
Three frameworks already own this space. Each made one structural mistake we're not making — and the difference shows up in code you write every day.
Hello, world
The smallest unit — declare a route. Same outcome, three philosophies.
// src/pages/hello.page.tsx
import { page } from '@place-ts/component'
export default page('/hello', {
view: () => <h1>Hello</h1>,
})
// app/hello/page.tsx
export default function Page() {
return <h1>Hello</h1>
}
// (file path = route; no value to reference)
// app/routes/hello.tsx
export default function Hello() {
return <h1>Hello</h1>
}
// (file path = route; framework wires it via convention)
Next and Remix both encode the route in the file path. Move the file, your route moves with it; references to it (links, action callers) get stale. place puts the route in a value — refactor it, TypeScript flags every call site.
Server actions
Mutation is the API people get wrong first. The 'use server' marker hides too much; the FormData contract throws away types.
export default page('/posts/:id', {
on: {
save: async (input: { title: string }, { params }) => {
await db.posts.update(params.id, input)
return { ok: true }
},
},
view: () => /* call pageRef.save({...}) — fully typed */ null,
})
// app/posts/[id]/page.tsx
'use server' // ← required marker
export async function save(formData: FormData) {
// formData is untyped; you destructure strings.
await db.posts.update(/* id from cookie? closure? */, {
title: formData.get('title') as string,
})
}
// caller imports save(), passes FormData; types lost.
place's action lives on the same page as its caller, with the full input type intact. The endpoint is visible (POST /posts/:id/_action/save); the path appears in your routes table; no Babel pass, no encrypted action IDs, no untyped FormData detour.
Capabilities, not context
React's context is global by default and silent on SSR mismatches. Capabilities are typed, scoped, and SSR-aware out of the box.
import { defineCapability } from '@place-ts/capability'
export const NoteStoreCap = defineCapability<NoteStore>('NoteStore', {
clientOnly: true,
})
// In a page:
const store = NoteStoreCap.use() // typed; provided once at app config
const NoteStoreContext = createContext<NoteStore | null>(null)
function Provider({ children }: { children: ReactNode }) {
const value = useMemo(() => createStore(), [])
return <NoteStoreContext.Provider value={value}>{children}</NoteStoreContext.Provider>
}
function useStore() {
const v = useContext(NoteStoreContext)
if (!v) throw new Error('NoteStoreContext not provided')
return v
}
// 12 lines for what one defineCapability does.
clientOnly: true auto-emits an SSR-safe placeholder when a browser-only cap is touched during render. No typeof window branches. No hydration mismatches.
Feature matrix
| place | Next.js (App Router) | Remix | TanStack Start | |
|---|---|---|---|---|
| Routeshow routes are declared | values | file convention | file convention | file convention |
| Refactor by renameTS catches stale route refs | ✓ | — | — | partial |
| Typed mutationscaller knows the input type | ✓ | FormData only | FormData only | ✓ |
| Schema-validated actionsZod / Valibot / ArkType, typed field errors | ✓ | manual | manual | ✓ |
| No use client / use server markersno magic string directives | ✓ | — | ✓ | ✓ |
| No codegen stepno generated files to regenerate | ✓ | ✓ | ✓ | — |
| SSR-safe typed contextcapabilities — no typeof window checks | ✓ | — | — | — |
| Islands hydrationcontent pages ship zero framework JS | ✓ | — | — | — |
| Streaming SSRper-suspense boundaries | ✓ | ✓ | ✓ | ✓ |
| Built-in CSRFautomatic on every action | ✓ | origin-check | — | — |
| Built-in CSP + SRIstrict headers, hashed scripts | ✓ | — | — | — |
| Image componentsrcset + lazy variants + caching | pluggable | ✓ | — | — |
| Component library included@place-ts/design — 14 primitives | ✓ | — | — | — |
| Animation primitivesspring / tween / sequence as derived state | ✓ | — | — | — |
| RBAC gate component<Can do="…"> reads the session | ✓ | — | — | — |
| View Transitionsopt-in, zero extra JS | ✓ | — | — | — |
| Islands-aware static exportpre-render + ship only island bundles | ✓ | partial | — | partial |
| Bundle — hello worldgzipped client JS, content page | 0 KB | 74 KB | 38 KB | 52 KB |
When not to pick place
- You need Node-specific integrations. place runs on Bun; the first-party adapters ship for Cloudflare Workers, Vercel Build Output, and Deno Deploy via
createFetchHandler(), but pure-Node compatibility is best-effort. - You have a large React codebase with deep React-specific patterns. place uses a different reactivity model; the migration cost is real.
- You need an established ecosystem. place is v0.x; the recipe library will be 10× smaller than Next's for a while.