Transforming data on write and read
This recipe shows how to rewrite a property's value as it enters or leaves the server using @write and @read resolvers on a schema property.
When to use this approach
Use @write when a stored value should be normalized before it hits storage — lowercasing a slug, trimming whitespace off a title, computing a derived value from one or more sibling properties. The resolver runs on the server, at write time, and what it returns is what ends up on the article.
Use @read when the stored value should be transformed before it reaches the caller — formatting a timestamp, joining a list into a presentation-friendly string, hiding a sensitive suffix. The resolver runs at read time, and what it returns is what the caller sees. The stored value is unchanged.
Do not use these for business-logic validation — that is what @validate expressions are for. A resolver that wants to reject bad input should let a validator catch it on the next pass.
Lowercasing a slug on write
The slug property of a Project schema should always be stored in lowercase. A @write resolver handles the normalisation inside the property declaration:
<li itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="slug">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<script type="text/sessel" itemprop="@write">
self.map((el) => {
let normalized = el.value().lowercase();
new meta[itemprop="slug"][content=normalized] {}
})
</script>
</li>
self is the list of every [itemprop="slug"] element in the incoming write — for a 1..1 property, a one-element list. The expression maps each element to a new <meta> element whose content is the lowercase form of the original value. The replacements go into storage in place of the originals.
A PUT carrying content="My-Project" is stored as content="my-project". A subsequent GET returns the lowercase form — the original casing is gone.
Formatting a date on read
The opposite direction: the stored value is a raw ISO 8601 timestamp, and reads should return a friendlier format. A @read resolver handles the transform on the way out:
<li itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="createdAt">
<meta itemprop="type" content="https://schema.host/DateTime">
<meta itemprop="cardinality" content="1..1">
<script type="text/sessel" itemprop="@read">
self.map((el) => {
let stamp = el.value().parseDateTime();
new time[itemprop="createdAt"][datetime=el.value()] {
stamp.format("d MMMM yyyy")
}
})
</script>
</li>
The stored <time itemprop="createdAt" datetime="2026-04-08T09:15:00Z"> round-trips through the resolver and comes out as <time itemprop="createdAt" datetime="2026-04-08T09:15:00Z">8 April 2026</time>. The datetime attribute keeps the machine-readable ISO form; the visible text is the formatted version.
Choosing between server-side and client-side
Both @write and @read can also be expressed as client-side JavaScript modules — see Schema definitions in HTML for the embedding syntax. The short version:
- If the transform must be authoritative, trusted, or visible identically to every reader, run it on the server in Sessel.
- If the transform depends on the reader's local state (their timezone, their preference, their viewport), run it on the client in a JavaScript module.
- For everything else, prefer the server — client-side transforms are invisible to tools that do not run the JavaScript.