Let's push further into the intersection of LLMs and Angular. Earlier, we established a foundation with structured outputs and tool use. Now, we shift focus to a more ambitious target: moving beyond data-driven forms and enabling the model to influence the visual layer directly.
To achieve this, we will combine Gemini with **Nano Banana Pro**, Google's leading image generation model, to render Angular components dynamically based on the model's responses. We will aim to maximize the return on our code investment, writing as little as possible to achieve a highly interactive result. Along the way, we can also take the opportunity to explore Angular's latest addition: signal forms, whose inherently dynamic nature aligns perfectly with Generative UI principles.
Note: throughout this series, we often use cost-effective models like Gemini Flash to keep things accessible for learners. For production-grade Generative UI, using capable models is crucial, so always balance performance with your budget.
Let's begin.
Visualizing user preferences to help make decisions
Consider a car dealership application. A key part of the user journey involves contemplating the aesthetics and design of a potential purchase. Building a custom 3D rendering engine for this purpose is possible, but it presents significant drawbacks:
- substantial development time and effort to create such a component
- inherent limitations to predefined options and styles
- inability for users to see their car in different environments (e.g., how will my offroad car look in the mountains?)
An alternative is to leverage Nano Banana Pro to generate images of the car based on user descriptions within the app. This offers a richer experience with minimal code investment. However, before implementing this, it is prudent to weigh the pros and cons of this strategy against traditional rendering methods.
Pros:
- Flexibility: Users have the freedom to describe any design, and the model renders it accordingly.
- Ease of Implementation: No complex 3D components are required, simplifying the development process.
- Rich Visuals: The generated imagery can be of high quality and more visually compelling than simple renderings.
- Controlled Scope: Even though the model is capable of generating anything, it can be constrained to a specific domain (e.g., particular car models and styles), making output predictable.
Cons:
- Cost: Image generation models can be costly, especially at scale, particularly when using a frontier model like Nano Banana Pro.
- Latency: The generation process takes time, which could lead to noticeable delays in the user experience compared to rendering predefined assets.
- Quality Control: Generated images may sometimes not meet user expectations, potentially leading to dissatisfaction.
With these considerations in mind, let's implement this feature in our Angular application, incorporating signal forms as a key tool.
Implementation
First, we'll handle the backend part: requesting the image from the Gemini API. We'll add a simple Express.js endpoint for this task.
Note: if you don't have an Express.js backend yet, you can follow the instructions from my first article of this series to get a simple Express backend up and running quickly.
Warning: Nano Banan Pro is available only for the Paid tier of Gemini API access. To access it, you will need an API key that is tied to a Google Cloud Platform account with credits of a valid credit card.
app.post('/car-image', async (req, res) => {
const info = req.body;
try {
const response = await genAI.models.generateContent({
// Nano Banana is the code name of the image mode, but the actual model name is "gemini-3-pro-image-preview"
model: "gemini-3-pro-image-preview",
contents: "Generate a photo of a car that adheres to these specific parameters: " + JSON.stringify(info),
config: {
tools: [{ googleSearch: {} }],
imageConfig: {
aspectRatio: "16:9",
imageSize: "4K" // possible values are 1K, 2K, and 4K
},
}
});
const inlineData = response.candidates?.[0]?.content?.parts?.find(p => p.inlineData)?.inlineData;
const base64String = inlineData?.data;
const mimeType = inlineData?.mimeType;
if (!base64String || !mimeType) {
res.status(500).json({error: 'Failed to generate image'});
}
return res.json({image: `data:${mimeType};base64,${base64String}`});
} catch {
res.status(500).json({error: 'Failed to process the request'});
}
})
Note: the
googleSearchtool is only available for Gemini 3 class models, so you won't be able to use it with earlier models like Gemini 2.5 Flash.
Let's quickly summarize the logic behind this setup:
- We specify the image generation model (
gemini-3-pro-image-preview) for Gemini to use. - We take the user's input, place it within a simple prompt, and send it to the model.
- We define image configuration parameters, such as aspect ratio and size. Be mindful: larger dimensions increase both cost and generation time.
- Finally, we extract the base64-encoded image from the model's response and return it to the client.
A notable aspect here is the use of the tools field in the configuration, specifically the googleSearch tool. Unlike the tool calls we explored previously, which connected to our own application logic, this instructs Gemini to use a built-in Google search capability. This enables the model to retrieve up-to-date information and imagery from the web to enhance the output's accuracy. For instance, we don't need to supply detailed specifications about a car's appearance; the model can look it up itself.
This is quite straightforward. Now, let's add a method in our GenAIService so that Angular can call this endpoint.
type CarImageDetails = {
color: string;
background: string;
cameraAngle: string;
make: string;
model: string;
year: number;
}
const BASE_URL = 'http://localhost:3000';
@Injectable({providedIn: 'root'})
export class GenAIService {
readonly #http = inject(HttpClient);
// other methods omitted for brevity
generateCarImage(details: CarImageDetails) {
return this.#http.post<{image: string}>(${BASE_URL}`/car-image`, {details});
}
}
Next, we'll move to the TypeScript logic of our component. In this step, we'll use rxResource to call our backend endpoint and manage the image's state, paired with a simple signal form to collect user input.
@Component({/* */})
export class CarComponent {
readonly #genAI = inject(GenAIService);
imageDetails = signal({
color: '',
background: '',
cameraAngle: '',
make: '',
model: '',
year: 2000,
});
carMakers = ['Toyota', 'Ford', 'Honda', 'Chevrolet', 'BMW', 'Nissan', 'Tesla'] as const;
carModelsRaw: Record<typeof this.carMakers[number], string[]> = {
Toyota: ['Camry', 'Corolla', 'Prius'],
Ford: ['F-150', 'Mustang', 'Explorer'],
Honda: ['Civic', 'Accord', 'CR-V'],
Chevrolet: ['Silverado', 'Malibu', 'Equinox'],
BMW: ['3 Series', '5 Series', 'X5'],
Nissan: ['Altima', 'Sentra', 'Rogue', 'Pathfinder'],
Tesla: ['Model S', 'Model 3', 'Model X', 'Model Y'],
};
carModels = computed(() => {
const make = this.imageDetails().make as typeof this.carMakers[number];
return make ? this.carModelsRaw[make] : [];
});
form = form(this.imageDetails, path => {
required(path.make);
required(path.model);
required(path.background);
});
generatedImage = rxResource({
stream: () => this.#genAI.generateCarImage(this.imageDetails()),
defaultValue: {image: ''},
});
}
This snippet highlights several key features recently made possible with Angular's signal-based APIs:
- The Car Model dropdown options are dynamically populated based on the selected Car Make using a computed signal.
- Form validation is described declaratively using the
requiredfunction within a signal form. - The state of the generated image is managed by
rxResource, which gracefully handles loading and error states.
Finally, let's create the template for this component. It should reflect the simplicity of our logic.
<form>
<div class="control">
<label for="color">Color:</label>
<input id="color" type="color" [field]="form.color" />
</div>
<div class="control">
<label for="background">Background:</label>
<input id="background" type="text" [field]="form.background" />
</div>
<div class="control">
<label for="cameraAngle">Camera Angle:</label>
<input id="cameraAngle" type="range" min="0" max="360" [field]="form.cameraAngle" />
</div>
<div class="control">
<label for="make">Car Make:</label>
<select id="make" [field]="form.make">
<option value="" disabled selected>Select a make</option>
@for (maker of carMakers; track maker) {
<option [value]="make">{{ make }}</option>
}
</select>
</div>
<div class="control">
<label for="model">Car Model:</label>
<select id="model" [field]="form.model" [disabled]="carModels().length === 0">
<option value="" disabled selected>Select a model</option>
@for (model of carModels(); track model) {
<option [value]="model">{{ model }}</option>
}
</select>
</div>
<div class="control">
<label for="year">Car Year:</label>
<input id="year" type="number" min="1900" max="2024" [field]="form.year" />
</div>
<button type="button" (click)="generatedImage.reload()">Generate Car Image</button>
</form>
<figure>
<figcaption>Generated Car Image:</figcaption>
@if (generatedImage.isLoading()) {
<div class="loader-backdrop">
<div class="loader"></div>
</div>
}
@if (generatedImage.error()) {
<p>Error generating image: {{generatedImage.error()}}</p>
}
@if (generatedImage.hasValue() && generatedImage.value().image) {
<img [src]="generatedImage.value().image" alt="Generated Car Image" />
}
</figure>
As shown, the template remains uncomplicated because the heavy lifting is encapsulated within the signal form and the resource. We just bind the form controls to the signal form and render the image based on the resource's state.
Tip: if you're not fully familiar with Angular Resources, I recommend reading on of my past articles on the topic here. If you are not caught up with signal forms yet, check out this fantastic article from Manfred Steyer: All About Angular’s New Signal Forms, or take a look at two of my recent livestreams where I build with signal forms: Part 1 and Part 2. Alternatively, you can just read the official documentation here for a quick catching-up.
Let's take a moment to see how this component performs. I live in Armenia and drive a 2008 Nissan Pathfinder, often in the mountains, so let's try to see what a hypothetical image of my car would look like.

Great, the results are promising. Let's now move on to a more intricate use case.
Building the UI generation flow
Consider a familiar consumer situation: we have a mundane problem (the car won't crank, the milk has gone off), we open an LLM chat like Gemini, and ask for assistance. What we receive is a wall of text packed with steps and clarifying questions. We want to answer those questions to get a more useful response, but some are vague, or we are unsure about the options. Ultimately, we want concise actionable steps, yet we end up doing more and more prompting and may still get a disappointing outcome.
So, let's address this by building a highly adaptive UI where the LLM can pose clarifying questions, presented as form controls (with dropdown choices where relevant!) that the user completes, after which they receive the final action items as UI cards to resolve their problem.
This is a perfect fit for structured outputs and classic prompt crafting! Let's get to work.
Implementation walkthrough
To start, we obviously need a new Express.js endpoint that will manage the step-by-step UI generation. This one is more intricate than the last, so we will examine it piece by piece. First, let's clarify what kind of response we want from Gemini. We'll leverage structured outputs to define a schema with two primary sections:
const schema = {
"type": "object",
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"text": {
"type": "string"
}
},
"propertyOrdering": [
"title",
"text"
],
"required": [
"title",
"text"
]
}
},
"form": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"text",
"select",
"number"
]
},
"options": {
"type": "array",
"items": {
"type": "string"
}
},
"question": {
"type": "string"
}
},
"propertyOrdering": [
"type",
"options",
"question"
],
"required": [
"type",
"options",
"question"
]
}
}
},
"propertyOrdering": [
"steps",
"form"
]
}
}
If that looks overwhelming, it might be clearer to look at the matching TypeScript type:
export type ControlFieldType = 'text' | 'select' | 'number';
export type ControlSchema = {
type: ControlFieldType;
options?: string[];
question: string;
}
export type Step = {
title: string;
text: string;
}
type SchemaResponse = {
steps?: Step[];
form?: ControlSchema[];
}
As shown, we either expect Gemini to direct us to render controls with questions for the user when extra details are needed, or to provide concrete steps to fix the issue when the user's input is adequate. Here is what the endpoint looks like:
app.post('/fix-it', async (req, res) => {
const { query, additionalInfo } = req.body;
const prompt = `The user will provide an issue they are facing in their day-to-day life. You task is to find a solution and present it in actionable steps. If additional information is not provided, and knowing that additional information will help find the solution steps, return a list of items the user has to respond to. Those will be presented to the user as UI elements like dropdowns or inputs where they will input necessary information for you to provide a solution. User query: \n\n${query}, additional info ${additionalInfo ? JSON.stringify(additionalInfo) : 'not provided'}`;
try {
const response = await genAI.models.generateContent({
model: "gemini-3-pro-preview",
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: 'application/json',
responseJsonSchema: schema
}
});
res.json(JSON.parse(response.candidates[0].content.parts[0].text));
} catch {
res.status(500).json({error: 'Failed to process the request'});
}
});
Notice that we leaned much more into prompt engineering this time, supplying extra context when it exists, and reusing the same Google Search tool to assist the model in finding relevant web information if needed. We also enforced a strict structured JSON response based on our schema once again. Let's add a new method to our GenAIService to invoke this endpoint:
const BASE_URL = 'http://localhost:3000';
@Injectable({providedIn: 'root'})
export class GenAIService {
readonly #http = inject(HttpClient);
// other methods omitted for brevity
solveIssue(
data: {query: string, additionalInfo?: Record<string, string>}
) {
return this.#http.post<{form?: ControlSchema[], steps?: Step[]}>(`${BASE_URL}/fix-it`, data)
}
}
Now, let's pause and reflect on what we just did: instead of having one endpoint for gathering clarifying details and another for generating the actual answer, we built a single endpoint that handles both! While this keeps our codebase a bit tidier and possibly prevents scenarios where the model still requests more information even when the user has given everything in their query, it does mean we have to manage more intricate logic on the client side. This strategy is by no means "superior" to the two-endpoint method, so be sure to weigh your specific use case and pick accordingly.
Now, to build the actual UI, it makes sense to divide it into three components: one that takes the form schema and produces the necessary controls, one that receives the steps and shows them as cards, and a parent component that manages state and handles backend calls. Let's begin with the form component:
@Component({
selector: 'app-dynamic-form',
standalone: true,
imports: [Field],
template: `
@if (schema().length > 0) {
<form class="dynamic-form">
@for (control of schema(); track $index) {
<div class="form-field">
<label [for]="control.question" class="form-label">{{ control.question }}</label>
@switch (control.type) {
@case ('text') {
<input
[field]="$any(dynamicForm)[control.question]"
[id]="control.question"
type="text"
class="form-input">
}
@case ('number') {
<input
[field]="$any(dynamicForm)[control.question]"
[id]="control.question"
type="number"
class="form-input">
}
@case ('select') {
<select
[field]="$any(dynamicForm)[control.question]"
[id]="control.question"
class="form-select">
@for (opt of control.options; track $index) {
<option [value]="opt">
{{ opt }}
</option>
}
</select>
}
}
</div>
}
<button type="button" class="primary-btn" (click)="onSubmit()">Submit</button>
</form>
}
`,
})
export class DynamicFormComponent {
schema = input.required<ControlSchema[]>();
submit = output<Record<string, string>>();
formValue = linkedSignal(() => {
const s = this.schema();
const group: Record<string, any> = {};
s.forEach(c => group[c.question] = '');
return group;
});
dynamicForm = form(this.formValue);
onSubmit() {
const additionalInfo = this.dynamicForm().value()
this.submit.emit(additionalInfo);
}
}
Here, the key piece is the linkedSignal, which is generated on the fly from the input schema but remains mutable on its own (unlike a computed), making it easy to pass into the signal form. The remainder is standard dynamic form rendering. Also, note that we rely on $any in the template quite a bit, purely due to the completely dynamic nature of this component (we have no clue what inputs the LLM may ask us to render).
Once the user finishes filling things out, they hit the button and we emit the gathered values to the parent. Next up, let's create the steps component:
@Component({
selector: 'app-cards-stepper',
standalone: true,
template: `
@if (steps().length > 0) {
<div class="stepper-container">
<button class="nav-arrow prev" (click)="prev()" [disabled]="currentIndex() === 0" aria-label="Previous step">
<span aria-hidden="true"><</span>
</button>
<div class="steps-wrapper">
@if (hasPrevious()) {
<div class="step-card previous" (click)="prev()">
<div class="step-content">
<h3 class="step-title">{{ steps()[currentIndex() - 1].title }}</h3>
<p class="step-text">{{ steps()[currentIndex() - 1].text }}</p>
</div>
</div>
}
@if (steps()[currentIndex()]) {
<div class="step-card current">
<div class="step-content">
<h3 class="step-title">{{ steps()[currentIndex()].title }}</h3>
<p class="step-text">{{ steps()[currentIndex()].text }}</p>
</div>
</div>
}
@if (hasNext()) {
<div class="step-card next" (click)="next()">
<div class="step-content">
<h3 class="step-title">{{ steps()[currentIndex() + 1].title }}</h3>
<p class="step-text">{{ steps()[currentIndex() + 1].text }}</p>
</div>
</div>
}
</div>
<button class="nav-arrow next" (click)="next()" [disabled]="currentIndex() === steps().length - 1" aria-label="Next step">
<span aria-hidden="true">></span>
</button>
</div>
}
`,
})
export class CardsStepperComponent {
steps = input<Step[]>([]);
stepChange = output<number>();
currentIndex = signal(0);
hasPrevious = computed(() => this.currentIndex() > 0);
hasNext = computed(() => this.currentIndex() < this.steps().length - 1);
prev() {
if (this.hasPrevious()) {
this.currentIndex.update(i => i - 1);
this.stepChange.emit(this.currentIndex());
}
}
next() {
if (this.hasNext()) {
this.currentIndex.update(i => i + 1);
this.stepChange.emit(this.currentIndex());
}
}
}
Even though this component appears a little involved, it is actually pretty simple: we just present the current step, and when applicable, the prior and next steps as cards. Users can move between steps via arrows or by clicking on the cards themselves. Finally, let's implement the parent component that manages everything—this is where the real logic lives:
@Component({
selector: 'app-fix-it',
template: `
<div class="fix-it-container">
<h2 class="title">Fix your issue</h2>
<div class="input-section">
<label for="query" class="sr-only">Describe your issue</label>
<textarea
id="query"
[field]="form.query"
rows="4"
class="form-input query-input"
placeholder="Describe your issue..."></textarea>
<button type="button" class="primary-btn" (click)="result.reload()">Get Fixes</button>
</div>
@if (result.isLoading()) {
<div class="loading-backdrop">
<div class="loader"></div>
</div>
}
@if (result.hasValue() && result.value()) {
<div class="results-section">
@if (result.value().form) {
<app-dynamic-form
[schema]="result.value().form!"
(submit)="resubmit($event)"
/>
}
@if (result.value().steps) {
<app-cards-stepper [steps]="result.value().steps!"></app-cards-stepper>
}
</div>
}
</div>
`,
imports: [DynamicFormComponent, CardsStepperComponent, Field]
})
export class FixItComponent {
readonly #genAI = inject(GenAIService);
controls = signal<{
query: string, additionalInfo?: Record<string, string>
}>({query: ''});
form = form(this.controls, path => {
required(path.query);
});
result = rxResource({
stream: () => {
const {query, additionalInfo} = this.form().value();
if (this.form().invalid()) {
return of(undefined)
}
return this.#genAI.solveIssue({query, additionalInfo});
}
});
resubmit(additionalInfo: Record<string, string>) {
this.controls.update(c => ({...c, additionalInfo}));
this.result.reload();
}
}
We start with a basic form containing one input for the user's query, then we trigger a resource reload using our Gemini API call. The response might include steps or a form schema, and we render the matching component accordingly. When we get a form schema, we also hand over a handler that captures the submitted extra info and triggers another reload with that new data. Angular signals, forms, and resources make this remarkably effortless! So, just like the earlier example, here is how this component behaves in action:

And that's a wrap!
Wrapping up
Throughout this article, we expanded our grasp of Generative UI by pairing Gemini's text generation with Nano Banana Pro's image creation capabilities, which means we have officially dipped our toes into multimodality. Crafting generative UIs is a core part of building AI-driven applications, and in my view, GenUI-style interfaces will grow more common and widespread as the field advances.
In the upcoming article, we will dive into embeddings—a powerful idea that enables far more than generative experiences, opening the door to semantic searches, recommendation systems, and knowledge-centric applications like RAGs (retrieval-augmented generation). Keep an eye out!
Quick note

My book, Modern Angular, is now available in print! I invested a huge amount of time covering every new Angular feature from v12-v18, including improved dependency injection, RxJS interop, Signals, SSR, Zoneless, and much more.
If you are working on a legacy codebase, I think my book will help you get up to speed with everything fresh and exciting that our favorite framework offers. Grab it here: https://www.manning.com/books/modern-angular
P.S There is a chapter in my book that covers working with LLMs in Angular apps; that chapter is already somewhat outdated, even though the book was published just earlier this year (proof of how incredibly fast the AI world moves!). I hope you'll forgive me ;)

