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/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/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 refs | ✓ | — | — | partial |
| Typed mutationscaller knows input type | ✓ | FormData only | ✓ | ✓ |
| No codegen stepno .d.ts to regenerate | ✓ | ✓ | ✓ | — |
| SSR-safe contextno typeof window checks | ✓ | — | — | — |
| Streaming SSRsuspense boundaries | ✓ | ✓ | ✓ | ✓ |
| Built-in CSRFauto on every action | ✓ | — | ✓ | — |
| Built-in image optsharp + content hash | ✓ | ✓ | — | — |
| View Transitionsopt-in, zero JS | ✓ | — | — | — |
| Bundle size (hello world)compressed client | 18 KB | 74 KB | 38 KB | 52 KB |
When not to pick place
- You already ship on Vercel's edge runtime and want native integration. place runs on Bun; adapters for other runtimes are in the roadmap, not shipped.
- 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.