article > tech
How to Build an App That Generates UI with AI?
Introducing the gen-ui 3-layer concept for implementing generative UI. This post presents a concrete methodology for effectively controlling and implementing AI-generated UI results by dividing the generation process into three abstraction levels - code, DSL, and Micro Frontend composition.
In this post, I’ll introduce the ‘gen-ui 3-level’ concept I devised for implementing generative UI.
The gen-ui 3-level framework is a technical abstraction for implementing generative UI. It’s also a mental model for discussing how an application interacts with AI to generate UI. By understanding and utilizing these three levels, you can control the UI output generated by AI to your liking. If my previous post, “How will AI change software UI?”, was about the vision and ways AI can innovate UI, this post is about how to make that vision a reality.
All UI generation methods across the three levels share the same general flow: a user’s unstructured, natural language query is converted into a UI. However, the final output produced by the AI differs at each level. You can think of it as producing higher-level outputs as you move up the levels.
Level 1: Code Generation
The AI directly generates UI code based on the user’s request. For prompts like “Create a blue confirmation button” or “Make a sign-up form,” the LLM generates React or HTML code directly. This code is then evaluated in a sandbox that can compile/render it, displaying the UI to the user.
Since the code is created from scratch, this level offers the highest degree of user freedom and customization. It can support fine-grained customizations that are unattainable with Level 2 and Level 3, which I’ll explain later. However, guaranteeing the quality of the gen-ui output is difficult, so it’s crucial to ensure maximum predictability and quality at the code generation stage.
Services like v0.app, which generate code and UI from user chat prompts, are related to this level. By default, v0 uses shadcn and tailwind for code generation if the user doesn’t specify otherwise. These two tools are modern, and their thin abstraction layer helps in controlling the quality of AI-generated code.
v0 has placed a ceiling on customization. This also seems to be a measure to control code quality. MCP integration is not available in v0. And while it supports customization by registering shadcn’s registry, the docs don’t provide concrete examples and make it difficult to implement.
From a product developer’s perspective, unless the tool is specifically for other developers like v0, there’s no need to provide code to general users who lack a development background. A more appropriate approach might be to move to a higher level that offers more control and produces a higher-level output.
From the standpoint of AI transformation within a development organization, this idea is quite significant. If members of a product development team can request a specific UI via chat and receive the UI and code that uses the organization’s design language and system, it can be used as a highly versatile internal tool.
PMs or designers can prototype their ideas. Even team members without development knowledge can create applications with the look and feel of the company’s current products just by using chat. This allows them to easily perform tasks that previously required a developer, such as developing event or promotion pages or automating marketing tasks.
Since the AI can produce broken code, it’s beneficial to have a feedback loop that can handle compilation failures or errors and process that feedback directly.
Level 2: DSL Generation
To solve the unpredictability and quality issues of Level 1, we can take a more abstract approach. Instead of asking the AI to generate code directly, we ask it to generate an intermediate, agreed-upon language that describes the UI—a Domain-Specific Language (DSL).
For example, if a user requests, “Show me a list of this month’s new sign-ups in a table,” the LLM generates a JSON-based DSL like the following, instead of code:
{
"component": "DataTable",
"props": {
"columns": ["name", "email", "createdAt"],
"data_source": "users_this_month"
}
}
The AI’s role is to translate natural language into structured data, like a predefined JSON object. The application is then responsible for rendering this DSL into the actual UI. The application interprets the DSL according to predefined rules and creates the UI in a set manner.
// Example code for converting DSL to UI
import { DataTable, Card, UserProfile } from './components'; // Predefined components
// An object mapping component names to their actual implementations
const componentMap = {
DataTable,
Card,
UserProfile,
// ... register all components that can be generated via DSL
};
/**
* A function that takes a DSL object and returns the corresponding React element
*/
function renderFromDsl(dsl) {
// 1. Find the component type to render from the DSL object.
const ComponentToRender = componentMap[dsl.component];
// 2. If the component is not in the map, return a fallback UI.
if (!ComponentToRender) {
return <div>Component '{dsl.component}' not found.</div>;
}
// 3. Render the found component, passing the props directly.
return <ComponentToRender {...dsl.props} />;
}
This approach allows for better quality control than generating raw code (Level 1) and enables appropriate control over customizable areas. The AI cannot deviate from the contract defined by the DSL schema. For product developers, this hedges against the uncertainty of user requests, which can ask for anything via the LLM. Ultimately, the AI can only modify the DSL, and if it generates broken code or UI, assuming the AI was prompted correctly, it’s likely a bug in the application. There’s even the case of Banksalad’s “Salad Game,” which applied code generated in-product to production by imposing strict constraints with a DSL, feedback loops, and tests.
By nature, a DSL schema defines and manages all customizable areas. You have to define what’s possible and what’s not from the beginning, which means spending a lot of time on planning and spec alignment. There will also be many things you learn by building.
The AI needs to understand the DSL grammar. While LLMs are very good at generating code, this case introduces another layer of knowledge: the DSL. The DSL shouldn’t be too different from the code or data formats the AI already knows, so that it can successfully understand and edit it.
The DSL spec can also be used to provide an input-style UI for directly changing customizable areas. Figma Make, for example, allows users to edit generated code directly in the UI. This serves as an excellent supplement to chat.
Level 3: Micro Frontend Composition
The highest level of abstraction involves the LLM assembling large, complex UI chunks that have already been independently developed and deployed. Micro-apps, appropriately decoupled and developed on a Micro Frontend architecture, have their own identity and an identifier like a URL to call them.
Just as drawing a magic circle summons a creature, you can summon micro-apps from specific domains into another web app where a Micro Frontend architecture is valid. This allows you to compose various micro-apps to create a web app that performs the actions the user wants.
In response to an unstructured user query, the LLM retrieves a list of available micro-apps and selects their identifiers to render them. For example, <MicroAppLoader /> is a special React Component that can ‘summon’ a remote micro-app by taking name and module as identifiers.
// Summoning the "Tour Reservation Modal" UI from the travel app
<MicroAppLoader
name="travel"
module="TourReservationModal"
/>
// Summoning the "My Stock View" UI from the finance app
<MicroAppLoader
name="finance"
module="MyStockView"
/>
Let’s imagine a user makes a complex request like, “Show me our mall’s sales figures for this week and the top 5 best-selling products, and let me send a promotional email right away.”
Upon receiving this request, the LLM already knows the list of pre-registered micro-apps and their capabilities. Here, name is the unique identifier for the remote application, and module refers to a specific functional component exposed by that application. My blog readers might have already guessed it, but this is the way Webpack Module Federation identifies federated apps.
If we use the {name}/{module} format to represent the apps the LLM chooses to compose, it would look like this:
sales/Dashboard- A dashboard that visualizes sales data by period.products/TopList- A list that displays top-selling products.marketing/EmailComposer- An editor for writing and sending marketing emails.
The LLM interprets the user’s natural language request and extracts three key intents: ‘view sales,’ ‘rank products,’ and ‘send email.’ It then selects the most suitable micro-apps as its ‘summons.’ It could generate a JSON like this:
{
"layout": "grid-2x1",
"apps": [
{
"key": "sales-dashboard-weekly",
"name": "sales",
"module": "Dashboard",
"props": { "time_period": "this_week" }
},
{
"key": "top-products-list",
"name": "products",
"module": "TopList",
"props": { "count": 5 }
},
{
"key": "promo-email-composer",
"name": "marketing",
"module": "EmailComposer",
"props": { "template": "new_promotion" }
}
]
}
Actually, the way the LLM assembles micro-apps isn’t that different from the Level 2 DSL approach. If we were to be precise, this is less of a DSL itself and more of an Execution Plan.
Now, the application responsible for composing these apps takes this DSL ‘blueprint’ and uses <MicroAppLoader /> to assemble each micro-app at runtime to construct the screen.
// Example of a Host App rendering MicroApps from a DSL
function GenerativePage({ dsl }) {
// You could also dynamically select a layout component based on dsl.layout
return (
<GridLayout>
{dsl.apps.map((app) => (
<MicroAppLoader
key={app.key}
name={app.name} // Name of the remote app
module={app.module} // Name of the module (component) to load
props={app.props}
/>
))}
</GridLayout>
);
}
As a result, with a single request, the user gets a custom page where a sales graph, a product list, and an email editor are organically combined on one screen. This is all possible because the AI reassembles perfectly functional, independent UI chunks in real-time to fit the user’s context.
A similar real-world example is Shopify’s Composable Commerce. Shopify defines independent business functions like catalog, cart, checkout, and search as ‘Packaged Business Capabilities’ (PBCs), which can be seen as a similar concept to micro-apps like our sales/Dashboard or products/TopList.
This approach shines in applications for companies with horizontally scaled domains, each handling deep and complex business logic.
Let’s say a company is building its first AI product. They could create a new AI application and build generative UI snippets, DSL-based apps, or APIs on top of it. However, this would allow different domains to couple too easily with the new AI app, and it would create an environment where features already built in the deep domain apps are easily duplicated.
Even at my company (flex), the API requests and sequences a user goes through just to clock in already number in the dozens. If we were to re-implement that complex logic in a new AI app or put it in an npm package to integrate at build time for the sake of an AI product, developers would be miserable, and development speed would plummet. Integrating micro-apps defined in their original domain apps into the AI app is superior in terms of cohesion and work efficiency.
If we stretch our imagination a bit further, a world where we no longer need to plan where a specific feature should be located or displayed might be possible. You could have a single, canvas-like app that integrates micro-apps. Each deep domain app would develop small micro-apps and just expose them here. Then, in the canvas app, the LLM and the users themselves could directly combine these apps. The product as a whole is complex with deep domains, but there’s only one URL, and users create and discard applications as they please.
Mixing All Three
By freely switching between these three levels according to the user’s request, you can create more flexible and rich generative UI applications.
A user in a B2C fintech app requests, “Show my spending for this month. Draw a pie chart by category, and list the top 5 largest expenses.”
- Level 3 - The AI first analyzes the request and recognizes the need for two large, independent UI blocks: a ‘Spending Analysis Chart’ and a ‘Transaction History List.’ It then generates an Execution Plan to place these two micro-apps (MFAs) on the page.
{ "plan": [ { "action": "render_mfa", "target": "SpendingChartMFA", "slot": "main_top" }, { "action": "render_mfa", "target": "TransactionListMFA", "slot": "main_bottom" } ] } - Level 2 - To handle the specific requirements of ‘pie chart’ and ‘by category,’ the AI generates a DSL for the
CategoricalChartcomponent. This DSL is a clear specification of which type of component to render with what settings.{ "componentType": "CategoricalChart", "config": { "period": "this_month", "chartStyle": "pie", "groupBy": "category" } } - Level 1 - A moment later, the user adds, “Show a motivational message card above the pie chart that says ‘₩50,000 left to reach your savings goal!’” Since this UI is not an existing component, the system drops down to Level 1 and directly generates a Tailwind CSS-based ReactNode snippet to represent this message card, injecting it dynamically.
// Generated ReactNode snippet <div className="my-3 p-4 bg-gradient-to-r from-sky-400 to-emerald-400 text-white rounded-lg shadow-lg flex items-center space-x-3" > <span className="text-2xl">🎉</span> <div> <p className="font-bold text-lg">You're almost there!</p> <p className="text-sm">₩50,000 left to reach your savings goal!</p> </div> </div>
In this way, the AI acts not as a simple code generator, but as an intelligent orchestrator that determines the abstraction level of a request in real-time and selects the most appropriate strategy.
Closing Words
Appropriately combining these three levels, creating applications that produce generative UI, and easily integrating features from specific domains to create and maintain DSLs and execution plans in a way that LLMs can easily understand will become a key technical competency in developing and maintaining Generative UI applications.
I feel like I’m building that competency right now. I’m endlessly building, discarding, using, questioning, and contemplating.