| name | remult |
| description | Remult patterns - entities, fields, repo() usage, lifecycle hooks, permissions (allowApi + apiPrefilter), upsert, customFilter, sqlExpression, ValueList enums, relations. Use whenever writing or modifying Remult entities, configuring API permissions, working with `repo()`, or building CRUD flows in any Remult-powered app (React, Angular, Vue, SvelteKit, Next.js, SolidStart, Nuxt). |
Remult
Remult is a full-stack TypeScript framework: define an entity once, get a typed REST API, a typed client, validation, and permissions everywhere.
Need more docs? Fetch https://remult.dev/llms.txt - it's the curated index of every doc page. Pull the specific page you need from there.
repo() Usage
Always call repo(Entity) inline - never store in a variable. It's cheap, context-aware (frontend/backend), and per-request scoped.
await repo(Task).find(...)
Entity ID Field
Prefer @Fields.id() (UUID, no DB autoincrement coupling). Use @Fields.autoIncrement() only if a numeric DB-side sequence is required.
@Fields.id()
id!: string
Entity Declaration
import { Entity, Fields, Allow } from 'remult'
@Entity<Task>('tasks', {
allowApiCrud: Allow.authenticated,
allowApiDelete: 'admin',
})
export class Task {
@Fields.id() id!: string
@Fields.string() title = ''
@Fields.boolean() completed = false
}
Permissions: allowApi* vs. apiPrefilter
Two layers, applied in order:
allowApi* - entity-level gate. Accepts boolean | Allow.* | Role | Role[] | (item, remult) => boolean. Decides whether a caller can hit this entity for read/insert/update/delete at all. Keys: allowApiCrud, allowApiRead, allowApiInsert, allowApiUpdate, allowApiDelete.
apiPrefilter - row-level filter. Returns an EntityFilter that's automatically AND-ed into every API query (find, count, updates, deletes). Decides which rows the caller can see/touch.
@Entity<Task>('tasks', {
allowApiRead: Allow.authenticated,
apiPrefilter: () =>
remult.isAllowed('admin')
? {}
: { ownerId: remult.user!.id },
})
Use apiPrefilter for row-level security - never recreate the WHERE clause on the client. The prefilter runs server-side regardless of what the client sends.
Lifecycle Hooks
Order on save: saving -> save -> saved. On delete: deleting -> delete -> deleted. Each hook receives (entity, event); event.isNew distinguishes insert from update.
@Entity<Post>('posts', {
saving(entity, event) {
if (event.isNew && remult.user) entity.userId = remult.user.id
},
async saved(entity, event) {
if (event.isNew) {
}
},
})
Server-only Code in Hooks / BackendMethods
For Node-only deps (sharp, fs, ...), wrap the server section in if (import.meta.env.SSR) { ... } and dynamically import() inside the block. Vite drops the entire branch from the client bundle.
@BackendMethod({ allowed: true })
static async log(msg: string) {
if (import.meta.env.SSR) {
const { appendFileSync } = await import('fs')
appendFileSync('./logs/log.txt', `${new Date().toISOString()} ${msg}\n`)
}
}
Same pattern in hooks:
@Entity<Post>('posts', {
saved: async (post) => {
if (import.meta.env.SSR) {
const { appendFileSync } = await import('fs')
appendFileSync('./logs/log.txt', `${new Date().toISOString()} saved ${post.id}\n`)
}
},
})
Do not use if (!import.meta.env.SSR) return as an early-return - it does NOT strip the Node-only deps from the client bundle. Always wrap the server-only section in if (import.meta.env.SSR) { ... }.
For more (abstract-the-call, bundler exclusion), see https://remult.dev/docs/using-server-only-packages.
Entity-first vs. BackendMethod
Remult's main lever: put logic in entity hooks and let clients call repo(X).insert/update/delete. Reach for BackendMethod only for genuinely cross-entity or client-invisible flows.
| Entity hook (preferred) | BackendMethod (when needed) |
|---|
| Default a field on insert | Multi-entity transactions |
| Per-row validation | Aggregations across many repos |
| Single-row side effects | Cross-entity bulk/clone ops |
| Image optimization on save | Reads from entities not exposed to clients |
Repository Methods - upsert
upsert matches by where, updates with set if found, inserts if not. Idempotent and works in single or batch form.
await repo(Task).upsert({
where: { slug: 'hello' },
set: { title: 'Hello' },
})
await repo(Task).upsert([
{ where: { slug: 'a' }, set: { title: 'A' } },
{ where: { slug: 'b' }, set: { title: 'B' } },
])
Use it instead of hand-rolling find-then-insert-or-update.
ValueList Enums
Use over @Fields.enum / @Fields.literal it's more future-proof, supports extra properties (label, color, etc.) and behavior on each value, and is easier to maintain as the list grows.
import { ValueListFieldType, getValueList, ValueListInfo } from 'remult'
@ValueListFieldType()
export class TaskStatus {
static Open = new TaskStatus('open', 'Open', '#22c55e')
static Done = new TaskStatus('done', 'Done', '#94a3b8')
constructor(
public id: string,
public caption: string,
public color: string,
) {}
}
for (const s of getValueList(TaskStatus)) {
}
getValueList(EnumClass | fieldRef | fieldMetadata) returns the values (use to populate dropdowns). id is what's stored in the DB and sent over the API; caption is what you display.
Binding to <select> / query string
Cross any string boundary (HTML inputs, URLs, FormData, localStorage) via ValueListInfo.get(EnumClass):
toInput(instance) -> the id as a string
fromInput(idString) -> the instance
const info = ValueListInfo.get(TaskStatus)
<select
value={info.toInput(task.status)}
onChange={(e) => setTask({ ...task, status: info.fromInput(e.target.value) })}
>
{getValueList(TaskStatus).map((s) => (
<option key={s.id} value={info.toInput(s)} style={{ color: s.color }}>
{s.caption}
</option>
))}
</select>
const params = new URLSearchParams(location.search)
const status = info.fromInput(params.get('status') ?? '')
params.set('status', String(info.toInput(TaskStatus.Open)))
In Svelte/Vue/Angular template syntax with two-way binds on objects, you bind the instance directly and skip toInput/fromInput - reach for them only when something forces strings.
Field Metadata in the UI
Don't hard-code field labels, types, or validators in templates - read them from repo(X).metadata.fields.<field> so a single source of truth drives the UI.
@Entity('tasks', { allowApiCrud: true })
export class Task {
@Fields.string({ label: 'Task title' })
title = ''
}
const f = repo(Task).metadata.fields.title
f.label
f.key
f.valueType
f.options
<label htmlFor={f.key}>{f.label}</label>
<input id={f.key} placeholder={f.label} value={task.title} />
If a field doesn't set label, remult auto-generates one from the key (firstName -> First Name). Same auto-titleising applies to ValueList items.
Permission Checks in UI
Use entity metadata - never duplicate permission logic in components.
const canEdit = repo(Post).metadata.apiUpdateAllowed(post)
const canDelete = repo(Post).metadata.apiDeleteAllowed(post)
const canInsert = repo(Post).metadata.apiInsertAllowed
These re-evaluate with the current remult.user and (where relevant) the row, mirroring the server-side check exactly.
Relations - Typed Includes
@Fields.string()
authorId = ''
@Relations.toOne(() => User, { field: 'authorId' })
author?: User
Read with include:
await repo(Post).find({ include: { author: true } })
For one-to-many: @Relations.toMany(() => Comment, 'postId').
Reusable Filters - Filter.createCustom
Encapsulate complex/computed WHERE logic so it's reusable, type-safe, and runs server-side.
import { Entity, Fields, Filter, repo } from 'remult'
@Entity('orders')
export class Order {
@Fields.id() id!: string
@Fields.string() status = ''
@Fields.createdAt() createdAt = new Date()
static activeIn = Filter.createCustom<Order, { year: number }>(
async ({ year }) => ({
status: { $in: ['created', 'pending', 'confirmed'] },
createdAt: { $gte: new Date(year, 0, 1), $lt: new Date(year + 1, 0, 1) },
}),
)
}
await repo(Order).find({ where: Order.activeIn({ year: 2024 }) })
The arg generic types the call site; the filter body always runs on the server.
Computed Fields - sqlExpression
Fields backed by a SQL expression instead of a physical column. Filterable and sortable in one round-trip, no row loading.
@Entity('tasks')
export class Task {
@Fields.id() id!: string
@Fields.string() title = ''
@Fields.integer({ sqlExpression: () => 'length(title)' })
titleLength = 0
}
await repo(Task).find({ where: { titleLength: { $gt: 10 } } })
The function form receives (entity, args?, command?) so you can build dynamic expressions; pair with dbNamesOf(Entity) for safe identifiers.
SQL-Driven Relations / Derived Joins
Inline a related value via subquery using sqlExpression + dbNamesOf. Flat shape, single query, fully queryable from the API.
import { dbNamesOf, Entity, Fields, repo } from 'remult'
@Entity('orders')
export class Order {
@Fields.id() id!: string
@Fields.string() customerId = ''
@Fields.string<Order>({
sqlExpression: async () => {
const cust = await dbNamesOf(Customer)
const ord = await dbNamesOf(Order)
return `(select ${cust.city} from ${cust} where ${cust.id} = ${ord.customerId})`
},
})
customerCity = ''
}
await repo(Order).find({ where: { customerCity: 'London' } })
Use this when you want a derived column queryable by the API client without exposing the related entity or doing N+1.
Sharing Shape Across Entities
Prefer class extends class for shared fields + hooks. Concrete entities extend a base class.
abstract class Auditable {
@Fields.id() id!: string
@Fields.createdAt() createdAt = new Date()
@Fields.string() createdBy = ''
}
@Entity<Post>('posts', { allowApiCrud: Allow.authenticated })
export class Post extends Auditable {
@Fields.string() title = ''
@Fields.string() body = ''
}
@Entity<Comment>('comments', { allowApiCrud: Allow.authenticated })
export class Comment extends Auditable {
@Fields.string() postId = ''
@Fields.string() text = ''
}
For shared options (permissions, hooks), extract a typed helper returning EntityOptions<T> and spread it into each @Entity({...}).
Module Pattern
Bundle related entities + init logic into a module so apps register them in one line. See https://remult.dev/docs/modules.
Quick References