Problem
Schemas and forms are defined as JSON files, then a code-gen step produces .ts files that must be committed. The generated files are awkward to review, types are indirect, and there's no clean way to bundle multiple schemas into a single typed client.
Proposal
Replace JSON schema and form files with defineSchema() and defineForm() TypeScript functions exported from the fimo package. Types are inferred directly — no code-gen step, no generated files to commit. A createFimoClient() factory bundles them into a single fully-typed client with hooks and submit helpers.
fimo sync
A new fimo sync command is the single sync primitive for all local definitions. It reads defineSchema / defineForm files and pushes them to the API, and it also reconciles t() translation keys with the DB (the translation-specific behaviour is described in N3). It runs automatically as part of fimo deploy and fimo preview; otherwise it's a one-shot, invoked manually or by an AI agent when local definitions change.
Creating new definitions
Schemas and forms are scaffolded by the CLI — no JSON files, ever:
fimo schema create <Name>
Writes src/schemas/<Name>.ts with a defineSchema stub. Dev fills in fields, runs fimo sync.
fimo form create <name>
Same idea for forms: writes src/forms/<name>.ts with a defineForm stub.
AI agents (via the MCP server or in-project skill) call the equivalent tools and produce the same .ts files. One code path for humans and agents; one file format everywhere.
Scope — every project, one path
fimo/client is the runtime for every Fimo project when M4 lands — CLI-created and sandbox-created alike. The generated hook files (hooks-template-v3.eta) go away at the same time as the unified template (N1) drops the two-template split. One template, one runtime, no parallel code paths.
Existing projects with legacy JSON schemas re-create them as .ts via fimo schema create (same CLI path new projects use). No dedicated fimo migrate command to maintain — the create command is the migration path. Mechanical and safe: once every schema has a .ts twin, the JSON files are deleted.
Code shape
Each resource is a single .ts file. Types flow from the definition — no code-gen, no generated files. The client factory bundles them into one typed entry point.
defineSchema
src/schemas/BlogPost.ts
import { defineSchema } from 'fimo'
export default defineSchema({
uid: 'BlogPost',
fields: {
title: { type: 'string', required: true },
slug: { type: 'string', required: true, unique: true },
body: { type: 'richtext', required: true },
coverImage: { type: 'media', mediaType: 'image' },
publishedAt: { type: 'date' },
author: { type: 'relation', target: 'Author' },
tags: { type: 'relation', target: 'Tag', multiple: true },
},
})
defineForm
src/forms/contact-us.ts
import { defineForm } from 'fimo'
export default defineForm({
name: 'contact-us',
fields: [
{ name: 'email', type: 'email', required: true, label: 'Your email' },
{ name: 'subject', type: 'string', required: true },
{ name: 'message', type: 'textarea', required: true, maxLength: 2000 },
],
submitLabel: 'Send message',
})
createFimoClient
src/fimo.ts
import { createFimoClient } from 'fimo/client'
import BlogPost from './schemas/BlogPost'
import Author from './schemas/Author'
import ContactUs from './forms/contact-us'
export const fimo = createFimoClient({
schemas: { BlogPost, Author },
forms: { ContactUs },
})
// Fully typed — imperative & React Query hook APIs derived from the schemas above.
Framework integration
Every resource exposes both an imperative API (fimo.X.list(), fimo.X.getBySlug()) for loaders, RSCs and static generation, and React Query hooks (fimo.X.useList(), fimo.X.useGetBySlug()) for client transitions. Server-fetched data hydrates the hook cache automatically — no double fetch.
React Router v7
loader → hook · SSR + SSG
// app/routes/blog._index.tsx
import type { Route } from './+types/blog._index'
import { fimo } from '~/fimo'
// Runs at request time (SSR) — or at build time if the route is pre-rendered (SSG)
export async function loader(_: Route.LoaderArgs) {
const posts = await fimo.BlogPost.list({ sort: '-publishedAt', limit: 20 })
return { posts }
}
export default function BlogIndex({ loaderData }: Route.ComponentProps) {
// Hydrates from loaderData; stays fresh on the client via React Query.
const { data: posts } = fimo.BlogPost.useList(
{ sort: '-publishedAt', limit: 20 },
{ initialData: loaderData.posts },
)
return <PostList posts={posts} />
}
Next.js (App Router)
RSC · SSG · ISR
// app/blog/[slug]/page.tsx — Server Component
import { fimo } from '@/lib/fimo'
// Static generation at build — enumerate every post
export async function generateStaticParams() {
const posts = await fimo.BlogPost.list({ fields: ['slug'] })
return posts.map(({ slug }) => ({ slug }))
}
// ISR — rebuild hourly in the background
export const revalidate = 3600
export default async function Post({ params }: { params: { slug: string } }) {
const post = await fimo.BlogPost.getBySlug(params.slug)
return <Article post={post} />
}
TanStack Start
route loader · RQ hydration
// app/routes/blog.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fimo } from '~/fimo'
export const Route = createFileRoute('/blog')({
loader: () => fimo.BlogPost.list({ sort: '-publishedAt' }),
component: BlogIndex,
})
function BlogIndex() {
const { data: posts } = fimo.BlogPost.useList(
{ sort: '-publishedAt' },
{ initialData: Route.useLoaderData() },
)
return <PostList posts={posts} />
}