Routes as values
Every page() call produces a value. That value carries its path, its action callers, and its types. Move the file, the value moves; references stay sound because they point at the value, not the file path.
Declare
// src/pages/post.page.tsx
import { page } from '@place/component'
const postPage = page('/posts/:id', {
view: ({ id }) => <Article id={id} />,
})
export default postPage // a value
Register
Pages register through an explicit array. No file-system convention, no "must live in this folder", no page.tsx vs route.tsx guessing.
// src/app.ts
import post from './pages/post.page'
app({
pages: [home, post, archive], // explicit array, in any order
/* ... */
}).run()
Link with the value
import postPage from './pages/post.page'
// Anywhere in your app:
<Link to={postPage} params={{ id: '42' }}>read post</Link>
// Type error if you forget params, pass the wrong name, or move the path:
<Link to={postPage} params={{ slug: '42' }} /> // TS error: slug not in params
<Link to={postPage} /> works because the page value carries its path AND its param shape. If you rename the path or change the params, every link, every action caller, every navigate() call gets a TypeScript error — the rename catches them.
Call actions through the value
// post.page.tsx
const postPage = page('/posts/:id', {
on: {
save: async (input: { title: string }, { params }) => { /* ... */ },
},
view: () => (
<button onClick={() => postPage.save({ title: 'New' })}>save</button>
),
})
The page value carries its action methods directly. postPage.save({ ... }) is fully typed; the underlying fetch targets /posts/:id/_action/save with the current URL's :id resolved.
post.page.tsx from pages/ to features/blog/pages/ — update the import, every other line still works.What this rules out
place doesn't do file-system routing. Two pages with the same path throw on register. There's no auto-discovery — if a page isn't in the array, it isn't served. Both are intentional: the routes table is exactly the array you wrote.