Here is a question that sounds simple until you've actually shipped a UI: how many files does it take before nobody on the team, including you, can...
For further actions, you may consider blocking this person and/or reporting abuse
Groundbreaking stuff. Next week, let's replace JSON with YAML and call it a new paradigm. You literally just traded
<Component/>for{"type": "Component"}and swept the naming chaos under the rug. This doesn't fix the underlying issues, it just wraps them in a worse developer experience.If all I had done was change the syntax, I'd agree with you. The runtime is the point, not JSON.
Out of curiosity, how many files and how much setup do you need to go from an empty folder to a working payment button with real behavior? Mine starts with two files and no build step.
More importantly, what happens when that button has a bug in production? In my runtime I can override or disable its behavior by changing a single action in content.json, or even replace the UI entirely, without rebuilding or redeploying the application. The runtime picks up the new description immediately. That's the trade-off I'm exploring: moving common UI behavior from implementation into declarative data.
I agree, YAML has many advantages over JSON for human-written code and would fit here.
Are you kidding? It was sarcasm.
No - I understand you were being sarcastic; I wasn't :)
😁 This.
Thanks for reading! Just to clarify one point that isn't obvious from the article: LuciaCore isn't trying to replace HTML, CSS, or JavaScript. They're still the execution layer. The runtime simply moves developers one level higher, from describing implementation to describing intent.
From my POV the problem you're describing is solved by a Design System, Governance and QA not by reinventing the wheel. If you have a Design System with a button, all parts of the system consume this. This way it's all consistent and updates get cascaded. This solution just adds more complexity and doesn't scale, as you're asking Developers to change the way they work by learning some random JSON syntax.
That's a fair point. I don't think LuciaCore replaces Design Systems, governance, or QA. Those are proven solutions, especially for large organizations.
The problem I was trying to solve is different. Design Systems still rely on people following conventions correctly. LuciaCore moves part of that responsibility into the runtime, so rendering and behavior are standardized by construction rather than primarily by process.
My target audience is also different. It's aimed at solo developers, small teams, and even non-technical collaborators who don't have dedicated design system teams or complex delivery pipelines. Starting from an empty folder, they only need
index.htmlandcontent.jsonto build a working interactive UI.Regarding the "random JSON syntax" point, I don't really see it as introducing another language. The vocabulary is intentionally small:
type,style,action,props, and a few related concepts. The runtime understands those concepts and generates the implementation consistently. JSON is simply the serialization format, not the idea itself.There's also a trade-off around ownership. Large organizations often solve consistency through people, reviews, governance, and process. That works well at scale, but it also naturally distributes knowledge across many contributors. What I'm exploring is the opposite end of that trade-off: making the entire UI description compact enough that one person, a small team, or even an AI assistant can reason about the whole application without reconstructing relationships across hundreds of implementation files.
Operational flexibility is another reason I built it this way. If I discover a production issue in a button, I can override or disable its declarative action by updating
content.json, without rebuilding the application. The runtime immediately interprets the new description. That obviously isn't the right trade-off for every project, but it's the one I wanted to explore.So I don't see LuciaCore as replacing existing frontend ecosystems. It's an experiment in solving the same consistency problem from a different direction: using a runtime to encode conventions that would otherwise depend on organizational process.
Yup, the same way LuciaCore requires Devs to follow different conventions, it shifts the responsibility for no reason.
But smaller teams don’t need this then, they can just unify something like a button without the need for a new system.
But you’re asking WebDevs to now build in JSON rather than standard WebComponents or JS framework components - why?
But it’s not needed then in this context, eg with a button - here’s the file. Use this. Smaller team can just ensure they share components.
That’s what a shared Components does. It’s shared so the controller can do this without a json file
This particular problem has been resolved with a different industry standard. However experiments can be fun - I hope you had a great learning from it!
That's a fair point, and I agree that Design Systems, governance, and QA are proven solutions for large organizations.
The problem I'm exploring is different. A Design System standardizes reusable components. LuciaCore standardizes how the entire UI is described and executed. The runtime owns rendering, layouts, routing, actions, component resolution, content separation, and compiled-page caching as a single declarative system. That's a different abstraction level.
Regarding the "random JSON syntax" point, I don't really see it as introducing another language. The vocabulary is intentionally small: type, style, action, props, and a few related concepts. JSON is simply the serialization format. The runtime is the idea.
One clarification on the production-update point: a shared component still lives in source code. Changing its behavior still means changing the implementation and shipping a new build before users receive the update. LuciaCore interprets content.json directly at runtime. There is no compile step between the description and execution. That's not just "components written as JSON." It's a different execution model.
For very small projects, I agree that sharing a button component may be enough. LuciaCore isn't trying to optimize that single use case. It's exploring whether one person, a small team, or even a non-technical collaborator can own an entire interactive application through a single declarative description without introducing build tooling or deployment friction.
If your workflow is already optimized around React, a mature Design System, and a large engineering organization, then LuciaCore is probably not the right trade-off. That's completely fine. The project exists to explore a different set of trade-offs, not to replace the existing frontend ecosystem.
Really interesting read. My biggest question would be scalability. Once an application grows to hundreds of screens and thousands of nodes, does the JSON stay manageable, or do you end up recreating the same complexity in another format?
Great question. My current approach is to avoid repeating large JSON trees in the first place. Most UIs are composed from reusable components, so even if a page contains a thousand buttons, it's usually a thousand references to the same component or primitive rather than a thousand independent definitions.
There's also an item: mechanism alongside props:. While props: maps one-to-one, item: lets a component render one-to-many through iteration. In practice, that means a list of hundreds of similar elements can often be expressed as a single component plus a data collection instead of hundreds of repeated nodes. Of course, this only works well when those elements actually share the same structure, which is usually the case for tables, menus, cards, product lists, and similar UI patterns.
I appreciate that you repeatedly call it a prototype instead of claiming it's going to replace React or Vue. That's a much more credible position. Even if I wouldn't use it today, the DSL design itself is worth reading.
Thanks, I appreciate that. I think it's healthier to present it as an experiment with a different set of trade-offs than as "the next React killer." Even if the overall approach doesn't fit every project, I hope some of the ideas around declarative UI description and standardized rendering are useful to discuss.
I like that the judging includes writing quality, not just the final project. Some of the most interesting engineering decisions are hidden unless people explain why they built something a certain way.
Thanks! That was actually one of my goals with this article. The implementation itself only tells part of the story. The interesting part, at least to me, is explaining the reasoning and trade-offs behind each design decision.
So make your own Language
Why put limits on your work. People wonder why I don't put any effort into getting my udebtyn back or my life
Thanks for reading!
I'm treating this as an experiment rather than "the next framework". If you see architectural weaknesses or edge cases where this approach would break down, I'd genuinely like to hear them. That's much more valuable than people simply saying it's interesting.
One thing I couldn't fit into the article is that the action system ended up growing into a small declarative DSL. Besides navigation and API calls, it already supports conditions, pipelines, storage, custom modules, and arbitrary JavaScript execution when needed. The goal wasn't to remove flexibility, but to make the common path much more consistent.
This reminds me a little of the declarative ideas behind Flutter or SwiftUI, except you're describing everything with JSON and letting the runtime own the implementation. Interesting direction.
Thanks! I actually drew inspiration from the same general idea of describing UI declaratively, but I wanted to explore a different trade-off. Instead of building another component framework, I wondered how far a small runtime plus a constrained declarative language could go while keeping the browser as the only execution environment.
@thuangf45 The customButton-plus-props approach is a real answer to the variant question, and it's a better one than I gave you credit for — you're betting on one stable identity plus local overrides instead of a fixed enum, and for domains that don't cleave neatly into primary/secondary/danger that's the more honest model. Fair.
But watch where the drift actually lands with props:style. The cascade appends caller CSS last, so overrides work — right up until two screens both need "the same variation." Now that visual delta lives inline at two call sites, and you're back to the exact copy-or-reference fork, just pushed down one level from the component to the override string. The enum wasn't the point; enforced reuse was. If props:style is freely typeable, nothing stops the second caller from retyping the first caller's override, and there's no mechanism that notices they're now the same thing wanting to be a name. That's the seam I'd instrument.
On the action DSL I think you're on solid ground — sequencing plus a branch plus a module contract is a genuinely closed vocabulary, and pushing domain behavior into modules-implementing-a-contract is the right call. My worry evaporates as long as the branch condition itself stays dumb. The day
conditionneeds to parse something richer than "call a module and read a boolean" is the day the grammar starts growing whether you meant it to or not. Keep that predicate a module call and I think you've actually closed the escape hatch I was pointing at.That's a really thoughtful review, and I appreciate you taking the time to dig into the design instead of stopping at the JSON example.
I also think you're right about where drift can move. Today, props:style is intentionally an escape hatch. If the same override starts appearing repeatedly, that's a signal it should become part of the shared component instead of remaining inline. The runtime doesn't detect that today.
LuciaCore is still very much a prototype. My first goal was simply to prove that a declarative runtime could describe and execute a real interactive application directly from JSON, without a build step. Before investing in tooling, I wanted to validate that the architecture itself was practical.
If the community finds those capabilities genuinely useful, I'd be happy to add them as optional, schema-configurable features. Projects could enable or disable policies, warnings, or other architectural checks without changing the core execution model.
I also agree with your point about the action DSL. Keeping the vocabulary closed is a deliberate design goal. Even conditions are intentionally limited to a single level—they don't nest or grow into another programming language within the declarative runtime. If multiple checks are needed, they're composed as a pipeline rather than building larger boolean expressions. The runtime remains responsible only for sequencing, branching, and invoking modules; all domain-specific logic stays inside those modules.
So I don't disagree with your observation at all. I see it less as a flaw in the execution model and more as a tooling problem that sits one layer above it. If the project proves useful, those are exactly the kinds of capabilities I'd like to build next.
The interesting part of declarative UI runtimes is not fewer files by itself; it is moving the change surface closer to the product concept. If the runtime makes the common product edits smaller without hiding escape hatches, that is a real architecture win. If it only moves complexity into a schema, the pain comes back later.
Thanks, I think that's a good way to frame it.
That's exactly the trade-off I'm exploring. The goal isn't just fewer files, but keeping the declarative surface growing much more slowly than the product itself.
I separate the system into three concerns:
Once the components stabilize, they grow very slowly because they're reused everywhere.
Large datasets don't expand the schema either. A product grid with 10 items and one with 100,000 items use the same declarative description. The runtime binds API data through loops instead of requiring the schema to enumerate every item.
So I'm trying to keep the declarative description proportional to the UI structure, not to the amount of business data. And when something genuinely falls outside the model—complex effects or specialized business logic—I simply call JavaScript. I'd rather keep the common 95% declarative than force 100% into the runtime.
That proportionality test is strong: the description should grow with the structure of the UI, not with every implementation detail. A declarative layer earns its place when common product edits stay small, while the escape hatch remains clear enough for the weird cases.
Interesting, you basically ran into a same frustration I did, except you just handled the frontend. I got pissed off enough to overhaul everything 😅 V.A.L.I.D.
That's an interesting direction. It feels like we're optimizing different bottlenecks.
V.A.L.I.D. pushes performance by optimizing the execution model inside Blazor, while LuciaCore tries to reduce the amount of implementation people have to write in the first place.
My primary goal isn't to squeeze another few nanoseconds out of rendering—it's to make common UI changes possible without rebuilding the application, and to keep the UI description simple enough that even non-developers can modify it safely. Performance still matters, but reducing cognitive complexity is the first constraint.
Not quite, V.A.L.I.D. handles pretty much the full-stack. I used it for my accounting suite (500k LOC), 82% of that is generated by V.A.L.I.D. that includes front and backend code, including Unit tests, MCP, BOs, DALs, UI-linkage, etc. including wiring the V.A.V.I.D. HUD for UI fuzzy testing, all while getting a 6x performance boost over standard Blazor, by entirely ditching the VDOM, in favor of unmanaged slabs. It makes the 'cognitive complexity' as simple as it gets, you just say what UI element you want, eg. a button and in the dto, you say what are your constraints for it and what rules you want it to adhere to. That's it, everything else is generated and any adjustments you make propagate across the entire codebase at compile time, so 99% of errors surface before you even run it.
That's impressive. It sounds like we're reducing complexity from opposite directions.
V.A.L.I.D. minimizes the amount of handwritten code by generating it at compile time. LuciaCore minimizes the amount of code that needs to exist at all for common UI changes by interpreting a declarative description at runtime.
For me, the key trade-off is operational rather than computational. If a label, layout, workflow, or button behavior needs to change, I want that change to be possible by updating the UI description without producing a new build.
So I don't really see the two ideas as competing. V.A.L.I.D. optimizes the compile-time developer workflow, while LuciaCore explores whether a large class of UI changes can move out of the compile/deploy cycle entirely.
We're both trying to reduce cognitive complexity, just at different stages of the software lifecycle.
It does the UI too, by ditching the VDOM entirely, opting for unmanaged slabs, so essentially 128 bits that map directly to the backend variables. So no massive HTML, just 128 bits. Because they're unmanaged slabs, technically live-updating without needing to build should be possible
The inline style in your JSON example is the tell that this hasn't hit its real test yet.
"style": "background: #2563eb; ..."is exactly the hand-assembled, drift-prone implementation detail you set out to kill — you've just moved it from a CSS file into a string inside a JSON node. The moment a second button wants the same look, you either copy that string (drift is back) or you invent a way to reference a shared token, at which point you've reinvented CSS classes with more steps. The inconsistency you fought wasn't caused by HTML/CSS/JS being separate files; it was caused by there being no single source of truth for "what a primary button is." JSON-as-data doesn't give you that source of truth for free — it just relocates where you're allowed to be inconsistent.The thing that actually saved teams from your 200-file problem historically wasn't declarative-vs-imperative, it was constraint: a design system with a fixed vocabulary of variants, enforced so
background: #2563ebliterally cannot be typed inline. That's where I'd push LuciaCore next — not "describe any button as data" but "you may only referenceprimary,secondary,danger." Freedom to inline arbitrary style is the same footgun that gave you three different buttons the first time; a runtime that permits it will drift the same way, just in a new file extension.One concrete failure mode to watch:
action: "navigate::to::signup"is a tiny DSL, and DSLs grow. The first time someone needs a click that navigates and fires analytics and is conditional on auth state, that string either sprouts a grammar or you punt back to raw JS — and the escape hatch is exactly where the "same way every time" guarantee quietly dies.That's a thoughtful critique, and I actually agree with the underlying concern. If a declarative runtime simply moves inconsistency into another format, it hasn't really solved anything.
You're also right that the runtime currently allows inline styles as an escape hatch. Nothing prevents someone from styling a primitive directly instead of composing a reusable component. I don't consider that problem completely solved. The goal is to make reusable components the natural path so the surface where inconsistency can appear becomes much smaller, while still keeping an escape hatch for the uncommon cases.
That's also why I intentionally avoid modeling every component as fixed variants like primary, secondary, or danger. Those abstractions work well in many design systems, but they don't always map naturally to every domain.
Instead, I try to keep the component identity stable. A project might expose a single customButton component that everyone references, rather than gradually accumulating submitButton, loginButton, actionButton, and other near-duplicates. When a screen needs a small visual variation, it extends the shared component through props instead of creating another component with a new identity. Mechanically, the component's default style ends with a props:style placeholder, so caller-supplied CSS is appended last and overrides only the declarations it explicitly sets through normal CSS cascade rules. The runtime doesn't need any special style-merging logic. The default implementation stays shared, while customization remains local to the specific use case.
On the action DSL, I think the concern would be valid if the language kept growing. I deliberately keep the runtime vocabulary very small. The runtime understands sequencing (|>), conditional branching (condition ?=> actionIfTrue :=> actionIfFalse), a small set of system calls, and the execution contract for external modules. It doesn't need to understand analytics, payments, authentication, or any other domain-specific behavior. Those are simply modules that implement the contract, while the runtime only orchestrates execution.
So if a button needs to check authentication, fire analytics, and then navigate, that's composed from existing primitives instead of extending the language with another keyword or grammar rule.
Ultimately, that's the trade-off I'm exploring. The declarative layer stays focused on describing UI structure and common interaction patterns. Domain-specific complexity lives in reusable modules, while visual variations extend shared components instead of creating new identities. The runtime provides the execution model; JSON is simply the serialization format that describes it.
Hi thuangf45, I saw your post on declarative UI runtime. I'm curious, are you struggling to manage frontend code complexity? Clypify can help you automate content creation and distribution to WordPress and Medium. Free plan at clypify.com — no card needed.
Declarative UI runtime" is doing a lot of work in that title, so curious how far you took it — is this closer to a config/JSON-driven renderer (like a schema that maps to components), or something deeper where the runtime itself interprets state transitions and layout without a build step at all?
The maintenance pain with frontend code usually comes from one of a few places: prop-drilling and state sync across components, the coupling between business logic and rendering, or just the sheer number of ways to express the same UI (three different button variants because nobody remembered the first one existed). Which of those was the actual trigger for building this, versus something more structural like wanting hot-swappable UIs driven by server-side config?
Also curious about the tradeoffs you hit — declarative runtimes tend to buy you consistency and less boilerplate, but often cost you escape hatches for one-off custom behavior. How are you handling the "I need this one screen to just do something weird" case without it turning into an imperative escape valve that undermines the whole model?
Great questions.
It's closer to the second description than the first. I wouldn't describe LuciaCore as a JSON-driven component renderer where a schema simply maps to existing components. The runtime is the execution model: it resolves layouts, component composition, data bindings (props, item, content, api), navigation, actions, and rendering directly from the description, without a build step between the UI description and execution.
The original trigger was mostly consistency rather than hot-swappable UIs. I kept running into the same problems: too many ways to express the same screen, components gradually diverging, and understanding behavior requiring jumps across many implementation files.
That consistency problem ended up solving a second problem almost by accident: once UI is described as data instead of source code, it also becomes swappable at runtime without a build step.
On state, I don't have a separate state-management layer like React or Vue. State naturally flows through props, item, content, and api bindings. For example, navigating to a product page passes the selected product id as props:id; the destination resolves that into an API query, receives data, maps it back into props, and re-renders. The runtime is interpreting data flow rather than explicit reducers or mutable component state.
The vocabulary is intentionally small (button, text, row, column, props, item, api, etc.). Components expose their entire contract through props, so opening a component is usually enough to understand what it expects without tracing implicit bindings across many implementation files. One of the motivations was to reduce the number of subtly different UI abstractions that naturally appear as projects grow.
As for escape hatches, they're still there. Actions can invoke JavaScript when needed, so I'm not trying to eliminate imperative code completely. The goal is simply to keep the common 95% declarative and reserve imperative code for the genuinely unusual cases instead of making it the default.
The interesting part is that declarative UI only pays off if the runtime owns enough of the boring contract: state transitions, validation, rendering boundaries, and failure modes. Otherwise it just moves complexity from components into configuration. The win is not fewer files by itself; it is fewer places where product intent can drift.
Agreed — that's the bar I'm designing against. The runtime owns enough of that boring contract that the schema doesn't need to compensate: what you describe is what executes, nothing more, nothing implicit. That's what keeps it from just becoming complexity relocated into configuration.
The part I care about most is where behavior lives. An action or a state change happens at the place it's declared, not somewhere else it gets triggered from. That's what actually limits drift — not fewer files, but fewer places to go looking when something behaves unexpectedly.
It even shows up in debugging. Since the pipeline is just a declared sequence, every step has access to the execution context—built-in values like prev, result, action, id, as well as any context a module chooses to expose. I can temporarily insert console::log::${...} anywhere in the pipeline to inspect exactly what that point sees, without opening modules or rebuilding the application. When I'm done, I remove that single step. Because the pipeline and its context are just data, observing them is simply another declarative action.
the 'nothing in those two hundred files agreed about how a button should be built' line is the one. we hit the same wall around 150 components. inconsistency, not complexity, was the actual cost.
what helped wasn't yet another component abstraction: making the schema the unit of review. once you're looking at a config object in a PR diff instead of a JSX tree, the noise drops and regressions become visible.
curious how LuciaCore handles state that isn't purely in the data — things like hover effects, user triggered transitions, or optimistic updates. does that live in the schema or does it need a separate behavior layer?
The PR review point especially resonates with me. That was one of the side effects I didn't fully appreciate until I started using the runtime myself. Reviewing a declarative schema usually means there's much less implementation noise to mentally reconstruct before you can see what actually changed.
On the state question, I actually think there are two different kinds of state.
Local state is carried with the interaction itself. Every rendered element already knows its own context through its dataset (for example data-id, data-action, etc.). When an action fires, those values are mapped directly into props for the next step. So if a user clicks a product, dataset.id automatically becomes props.id, and the destination page can bind that into an API call like:
GET /v1/api/product?id={props.id}
The runtime resolves the flow by passing context between actions rather than maintaining a long-lived client-side store.
Global state is different. Things like authentication, user preferences, or shopping carts can live wherever they're most appropriate—localStorage, sessionStorage, cookies, caches, or another storage layer. The schema simply composes actions to retrieve and use them. For example:
storage::get::authToken |> api::call
The retrieved value becomes part of the current execution context and can flow into subsequent actions.
For things like hover, active, focus, media queries, or simple transitions, I don't really consider those application state. They're simply CSS behavior. Styles can be declared inline on the node, including pseudo-classes and responsive rules. If they become large or shared across many components, they can be extracted into reusable style modules. The same principle applies: keep the common case compact, and only introduce another abstraction when it actually reduces complexity.
For genuinely imperative cases like optimistic updates, complex animations, or highly specialized interactions, I don't try to force everything into the schema. That's where JavaScript modules come in through a simple contract (render() or execute()). The runtime is meant to keep the common path declarative while leaving an escape hatch for the cases where imperative code is the better tool.
Yeah yeah yeah
Thanks
Good
Thanks