The lean headless CMS
to replace Optimizely
One Rust binary, PostgreSQL and a React editor. Put SeeAMess on top of your live Optimizely site without writing to it, move editors over page by page, and cut loose when you are ready.
- 0.22 ms p99 delivery by path
- 28.5 MB idle, every role
- Never writes to Optimizely


The Optimizely path
Migration is a period, not a moment
SeeAMess sits on top of a running Optimizely site and never writes to it. Until the last step, going back means discarding the overlay: Optimizely was never changed, so there is nothing to restore.
Editors stay in Optimizely
Read only
A sync adapter copies Optimizely's content into SeeAMess as upstream versions. SeeAMess Delivery serves it, and your existing front-end runs unchanged against it.
seeamess-migrate sync --source "…" --watch 30- Optimizely
- System of record
- SeeAMess
- Serves reads
Editors move page by page
Editorial overlay
Editors start publishing in SeeAMess. Where an overlay version is published, it wins; everywhere else the upstream content shows through. Changes upstream are flagged for comparison.
GET /authoring/v1/ overlay? state=upstreamChanged - Optimizely
- Still syncing
- SeeAMess
- Overlay wins where published
Optimizely can be retired
Independence
One command adopts all upstream content as native content. Nothing visible changes and nothing is re-projected. The adapter is switched off.
POST /authoring/v1/ sources/ optimizely/ adopt - Optimizely
- Retired
- SeeAMess
- System of record
Parity checked, not assumed. seeamess-migrate verify against a live Optimizely CMS 12: 12 responses compared, no unexpected differences.
Same front-end. The existing front-end renders the same visible text from either CMS for all six demo pages.
Quiet cutover. After adopt, every delivery response is byte-identical to before.
The editor
Built for the people who publish every day
One React application that talks only to the public APIs. Forms are generated from the schema, opening a content is one request, and the initial bundle is 173 kB gzipped.


Versions and compareEvery save is a version. Pick two and see a field-by-field word diff. 

Works on a phoneOne pane at a time, 40 px touch targets, the navigation at the bottom. 

Overlay compareWhen Optimizely changes a page you already changed here, compare and choose. 

Access rightsInherited access lists with Optimizely's bits: read, create, edit, delete, publish, administer. 

Media libraryUploads typed by their bytes, content-addressed, with renditions on demand. 

UI extensionsES modules add field editors, panels, pages and widgets. This one counts words.
- Ctrl/Cmd+K quick search
- Signed preview links at three widths
- Autosave with conflict dialog
- Ready for review and reject
- Scheduled publish
- Recycle bin with restore
- References: "used by"
- English and Polish UI
- Dark theme
Why SeeAMess
Every goal rules something out
Optimizely's useful ideas are kept: a stable identity, immutable versions, a published pointer per language. The weight around them is not.
Fast and memory efficient
Delivery reads one pre-built document per request; the schema is compiled at build time.
Rules out: runtime type scanning, runtime proxies, an unbounded object cache.
Modular
Modules with one-way dependencies and no shared in-process state. One binary runs any subset of them.
Rules out: a static service locator, modules that reach into each other's tables.
Scalable
Each module is stateless and scales alone. PostgreSQL and the blob store hold all state.
Rules out: sticky sessions, per-node scheduled jobs, node-local caches that need a custom invalidation bus.
Secure
Every command and query passes one authorization interface. Sign-in is OpenID Connect only.
Rules out: a built-in user table, a first-admin registration page.
Easy to extend
Extension hosts for server logic, ES modules for the UI, both outside the core process.
Rules out: in-process plugins, a second UI framework.
A reversible path from Optimizely
SeeAMess runs over a live Optimizely site and never writes to it. Going back means discarding the overlay.
Rules out: Optimizely concepts in the core, writing into Optimizely's tables.
Architecture
One binary. Any shape you need.
Authoring is the only writer. Every command is one transaction that appends to an outbox; the Projector turns it into documents Delivery serves with a single indexed lookup. Delivery can run on a read replica, in another region, with no access to authoring tables.
authoringprojectordeliverymediajobseditorAll in one process for development and small sites; several processes with different roles in production.Performance
Measured against a budget set before the code
Delivery reads one pre-built document per request. No ORM, prepared statements, an ETag computed at projection time. Every target below was written down first; each bar shows how much of it the measured result uses.
Delivery, content by path
0.22 ms p99
Target p99 under 5 ms4%
p50 0.09 ms, 2,000 sequential requests
Conditional GET (304)
0.22 ms p99
Target p99 under 1 ms22%
p50 0.09 ms
Cold start to first request
13–25 ms
Target under 500 ms5%
authoring, projector and delivery roles
Resident memory, idle
28.5 MB
Target under 60 MB48%
every role; 9 MB with authoring, projector and delivery
4000 px JPEG to 1200 px WebP
105 ms median
Target under 150 ms70%
to JPEG 51 ms; pure Rust plus libwebp
Editor, initial JavaScript
173 kB gzipped
Target under 250 kB69%
rich text and admin load on demand
- 245 mspublish to visible on a separate delivery process (outbox poll 250 ms)
- 20,000contents with 42,000 versions synced in 40 s; repeat sync with no changes 8 s
- 0.24 msroute lookup p99 at that volume
- 30 MBthe whole engine binary, every role included
Measured 2026-11-13 on a development machine (Ryzen 7, 16 threads, local PostgreSQL 17), release build, one process running every role unless stated. Authoring save and publish latency (targets 30 ms and 50 ms p99) are not measured separately yet. Full results and known limits
Developer experience
Your content model is code you can review
Port an Optimizely model by changing base types and attributes. The front-end reads natural JSON keyed by field name.
Define content types[ContentType(Id = "2066d4d5-0dff-40c0-b42b-cc35e694fa73",
DisplayName = "Article",
Description = "A news or blog article.")]
[AllowedChildren]
public class ArticlePage : Page
{
[Localized, Field(Order = 10, DisplayName = "Summary",
Editor = Editors.Textarea)]
public string? Summary { get; set; }
[Field(Order = 20, DisplayName = "Image", Editor = Editors.Image)]
public ContentRef? Image { get; set; }
[Localized, Field(Order = 30, DisplayName = "Main body")]
public RichText? MainBody { get; set; }
/// <summary>Shared across languages (not localized).</summary>
[Field(Order = 40, DisplayName = "Author")]
public string? Author { get; set; }
}#[derive(ContentType, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[content_type(
id = "2066d4d5-0dff-40c0-b42b-cc35e694fa73",
base = "page",
display_name = "Article",
description = "A news or blog article."
)]
#[allowed_children()]
pub struct ArticlePage {
#[field(localized, order = 10, editor = "textarea")]
pub summary: Option<String>,
#[field(order = 20, editor = "image")]
pub image: Option<ContentRef>,
#[field(localized, order = 30)]
pub main_body: Option<RichText>,
/// Shared across languages (not localized).
#[field(order = 40)]
pub author: Option<String>,
}# Build the manifest from your types (Rust shown; C# uses seeamess-schema build)
SEEAMESS_CARGO_DIR=sdk/rust scripts/cargo run -p site-models -- --out manifest.json
# Push it. The server answers with a plan (added, changed, breaking) first.
seeamess schema push --manifest manifest.json \
--url http://127.0.0.1:5080 --token dev- Content types in code
- C# classes or Rust structs. Both SDKs derive the same field IDs, so a Rust model and a C# model of one type agree.
- No proxies, no startup scan
- The classes are plain records at runtime. The server never loads integrator assemblies; it reads a JSON manifest.
- Renames are free
- Field IDs are UUIDv5 of the type ID and the original name. Version data is keyed by ID, so renaming needs no data rewrite.
- A plan before anything changes
- Breaking changes, such as changing a field kind, are refused unless you push again with
--force.
Start with a read‑only overlay.There is nothing to roll back.
Run SeeAMess locally in a few commands, point it at a copy of your Optimizely database, and compare both delivery APIs before anyone changes how they work.