Building a Simple AI Chat Application with Spring AI and Angular

12 Jul 2025
21 mins read

placeholder

In this tutorial, we’ll build a simple AI-powered chat application using Spring AI on the backend and Angular on the frontend. This is a great starting point for anyone looking to integrate AI capabilities into their web applications.

What We’ll Build

We’ll create a simple chat interface where users can:

placeholder

The application will consist of:

Prerequisites

Before we start, make sure you have:

💡 Get 3 months free of IntelliJ Ultimate with the coupon: LoianeGroner.

Project Structure

Here’s how our project is organized:

spring-ai-angular/
├── api-ai/                          # Spring Boot backend
│   └── src/main/java/com/loiane/api_ai/
│       └── chat/
│           ├── SimpleChatService.java
│           ├── ChatController.java
│           └── ChatResponse.java
└── angular-ai/                      # Angular frontend
    └── src/app/
        └── chat/
            ├── chat-service.ts
            ├── chat-response.ts
            └── simple-chat/
                ├── simple-chat.ts
                ├── simple-chat.html
                └── simple-chat.scss

We’ll create an Spring Boot project and an Angular CLI project and place both in the spring-ai-angular folder.

Setting up the Spring AI Backend

Create a new Spring Boot project using Spring Initializr or your IDE.

Selections:

Dependencies:

Or select the AI dependency of your preference.

Check the pom.xml file

Ensure your pom.xml includes the necessary dependencies for Google Gemini integration:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-vertex-ai-gemini-spring-boot-starter</artifactId>
  </dependency>
</dependencies>

The dependency spring-ai-vertex-ai-gemini-spring-boot-starter provides the necessary components and auto-configuration for using Gemini’s API within a Spring Boot application, similar to what spring-ai-openai-spring-boot-starter provides for OpenAI.

Step 1: Configure Your Environment

When using Google’s AI in a Spring AI project, we need to configure two properties in the application.properties (or yaml) file:

spring.ai.vertex.ai.gemini.projectId=${GEMINI_PROJECT_ID}
spring.ai.vertex.ai.gemini.location=us-east4

Although the project Id can be shared, it is a good practice to also pass this value as an environment variable in case you are using different projects for different environments (DEV, QA, PROD).

Step 2: Create the Chat Service

Let’s create our SimpleChatService that handles the AI interactions:

// filepath: api-ai/src/main/java/com/loiane/api_ai/chat/SimpleChatService.java
package com.loiane.api_ai.chat;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class SimpleChatService {

    private final ChatClient chatClient;

    public SimpleChatService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    public String chat(String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}

Where:

And what’s happening here?

Step 3: Create the REST Controller

Now let’s create the controller that exposes our chat functionality:

// filepath: api-ai/src/main/java/com/loiane/api_ai/chat/ChatController.java
package com.loiane.api_ai.chat;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final SimpleChatService chatService;

    public ChatController(SimpleChatService chatService) {
        this.chatService = chatService;
    }

    @PostMapping
    public ChatResponse chat(@RequestBody String message) {
        return new ChatResponse(this.simpleChatService.chat(message));
    }
}

Key points:

The ChatResponse is a record so we can format the output:

public record ChatResponse(String message) {}

Step 4: Testing via HTTP request

If you are using IntelliJ IDEA Ultimate, you can create a file api.http with the following content (this is very convenient for HTTP request testing):

POST http://localhost:8080/api/chat
Content-Type: application/json

{
    "message": "Tell me a joke"
}

Alternatively, you can also use PostMan or similar tools to simulate the HTTP request.

If we submit the request, we might get something like the following output:

{
  "message": "Why don't scientists trust atoms? \n\nBecause they make up everything! \n \nLet me know if you'd like to hear another one! 😄  \n"
}

Creating the Angular Frontend

Generate a new Angular project using Angular CLI:

ng new angular-ai --routing

Step 1: Add Angular Material to the project

Before we start adding components and services, let’s install Angular Material as our component library:

ng add @angular/material

Follow the prompts by selecting your preferred theme (I’m using Azure and Blue) and if you’d like to add the typography.

All the steps are also documented here: https://material.angular.dev/guide/getting-started

Step 2: Create the Chat Service

First, let’s create a service to handle API communication:

ng generate service chat/chat-service

Note that since Angular v20, the naming convention has changed, and instead of a chat-service.ts file, we are not suffixing the service name with an hiffen.

// filepath: angular-ai/src/app/chat/chat-service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ChatResponse } from './chat-response';

@Injectable({
  providedIn: 'root'
})
export class ChatService {

  private readonly API = '/api/chat';
  private readonly http = inject(HttpClient);

  sendChatMessage(message: string): Observable<ChatResponse> {
    return this.http.post<ChatResponse>(this.API, { message });
  }
}

What’s happening here?

Key points:

Next, let’s create the ChatResponse interface, which matches the ChatResponse record. Use the following command to create the file:

ng generate interface chat/chat-response

With the followig content:

// filepath: angular-ai/src/app/chat/chat-response.ts
export interface ChatMessage {
  message: string;
}

Step 3: Generate the Chat Component

Let’s create our simple chat component:

ng generate component chat/simple-chat

Checkout the new Angular style v2025+: https://angular.dev/style-guide. Note the name is no longer simple-chat.componentts, but simple-chat.ts for a cleaner naming convention!

Now let’s implement our chat component:

// filepath: angular-ai/src/app/chat/simple-chat/simple-chat.ts
import { Component, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatToolbar } from '@angular/material/toolbar';
import { catchError, of } from 'rxjs';
import { ChatResponse } from '../chat-response';
import { ChatService } from '../chat-service';

@Component({
  selector: 'app-simple-chat',
  imports: [MatCardModule, MatInputModule, MatButtonModule, FormsModule, MatToolbar, MatIconModule],
  templateUrl: './simple-chat.html',
  styleUrl: './simple-chat.scss'
})
export class SimpleChat {

  private readonly chatHistory = viewChild.required<ElementRef>('chatHistory');
  private readonly chatService = inject(ChatService);
  private readonly local = true;

  userInput = '';
  isLoading = false;

  messages = signal<ChatResponse[]>([
    { message: 'Hello, how can I help you today?', isBot: true },
  ]);

  // Effect to auto-scroll when messages change
  private readonly autoScrollEffect = effect(() => {
    this.messages(); // Read the signal to track changes
    setTimeout(() => this.scrollToBottom(), 0); // Use setTimeout to ensure DOM is updated
  });

  sendMessage(): void {
    this.trimUserMessage();
    if (this.userInput !== '' && !this.isLoading) {
      this.updateMessages(this.userInput);
      this.isLoading = true;
      if (this.local) {
        this.simulateResponse();
      } else {
        this.sendChatMessage();
      }
    }
  }

  private trimUserMessage() {
    this.userInput = this.userInput.trim();
  }

  private updateMessages(message: string, isBot = false) {
    this.messages.update(messages => [...messages, { message, isBot }]);
  }

  private getResponse() {
    setTimeout(() => {
      const response = 'This is a simulated response from the AI model.';
      this.updateMessages(response, true);
      this.isLoading = false;
    }, 2000);
  }

  private simulateResponse() {
    this.getResponse();
    this.userInput = '';
  }

  private scrollToBottom(): void {
    try {
      const chatElement = this.chatHistory();
      if (chatElement?.nativeElement) {
        chatElement.nativeElement.scrollTop = chatElement.nativeElement.scrollHeight;
      }
    } catch (err) {
      console.error('Failed to scroll chat history:', err);
    }
  }

  private sendChatMessage() {
    this.chatService.sendChatMessage(this.userInput)
    .pipe(
      catchError(() => {
        this.updateMessages('Sorry, I am unable to process your request at the moment.', true);
        this.isLoading = false;
        return of();
      })
    )
    .subscribe((response: ChatResponse) => {
      if (response) {
        this.updateMessages(response.message, true);
      }
      this.userInput = '';
      this.isLoading = false;
    });
  }
}

What’s happening here?

Key Angular concepts used:

This component demonstrates modern Angular patterns and provides a clean, reactive chat interface that can work both in simulation mode for testing and with the real Spring Boot backend.

Step 4: Create the Chat Template

Here is the HTML that connects to our component:

<!-- filepath: angular-ai/src/app/chat/simple-chat/simple-chat.html -->
<mat-card class="chat-container">
  <div class="chat-header">
    <mat-toolbar>
      <span>Simple Chat</span>
    </mat-toolbar>
  </div>
  <div class="chat-history" #chatHistory>
    <div class="messages">
    @for (message of messages(); track message) {
      <div class="message">
        <div class="message-bubble" [class.user]="!message.isBot">
          
        </div>
      </div>
    }
    @if (isLoading) {
      <div class="message">
        <div class="message-bubble">
          <span class="typing">...</span>
        </div>
      </div>
    }
  </div>
  </div>

  <div class="chat-input">
    <mat-form-field class="full-width">
      <mat-label>Ask anything</mat-label>
      <input matInput [(ngModel)]="userInput" (keyup.enter)="sendMessage()">
      @if (userInput) {
        <button matSuffix mat-icon-button aria-label="Send" (click)="sendMessage()" [disabled]="isLoading">
          <mat-icon>send</mat-icon>
        </button>
     }
    </mat-form-field>
  </div>
</mat-card>

What’s happening here?

Key Angular concepts used:

Step 5: Style the Chat Interface

Now let’s add some style to our HTML:

// filepath: angular-ai/src/app/chat/simple-chat/simple-chat.component.scss
.chat-container {
  display: flex;
  flex-direction: column;
  height: 80vh;
  margin: 36px;
}

.chat-header {
  flex: 0 0 auto; /* Header doesn't grow or shrink */
}

.chat-history {
  flex: 1 1 auto; /* Chat history takes up available space */
  overflow-y: auto; /* Add scrollbar if content overflows */
  padding: 10px;
}

.messages {
  flex: 1;
  margin-bottom: 16px;
  overflow-y: auto;
  padding-right: 8px;
}

.message {
  margin-bottom: 8px;
}

.message-bubble {
  padding: 10px;
  border-radius: 10px;
  max-width: 80%;
  display: inline-block;
}

.user {
  background-color: #d7e3ff; /* Light blue for user messages */
  border-radius: 10px;
  align-self: flex-end; /* Align to the right */
  margin-left: auto;
}


.chat-input {
  flex: 0 0 auto; /* Input bar doesn't grow or shrink */
  padding: 10px;
}

.full-width {
  width: 100%;
}

.typing {
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  animation: typing 1s steps(10) infinite alternate; // changed from 2s to 1s
}

@keyframes typing {
  from {
    width: 0;
  }
  to {
    width: 100%;
  }
}

What’s happening here?

Step 6: Update the App component

Now that our chat interface is ready, let’s go back to the main component and add the remaing code.

// filepath: angular-ai/src/app/app.ts
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterLink, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, MatToolbarModule, MatButtonModule, MatIconModule, RouterLink],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {
  readonly title = 'AI-Spring-Angular';
}

This App component serves as our application shell, providing the main navigation toolbar and a router outlet where our chat component will be displayed.

The app.html file:

<mat-toolbar>
  <span class="title"></span>
  <button mat-button aria-label="Simple Chat" routerLink="/simple-chat">Simple Chat</button>
</mat-toolbar>
<router-outlet></router-outlet>

And finally, the SCSS file:

.title {
  margin-right: 20px;
}

Step 7: Routes and the main configuration

If an app.routes file was not created during the project creation, go ahead and create one with the following content:

import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', redirectTo: 'simple-chat', pathMatch: 'full' },
  { path: 'simple-chat',
    loadComponent: () => import('./chat/simple-chat/simple-chat').then(c => c.SimpleChat)
  },
  { path: '**', redirectTo: 'simple-chat' }
];

What’s happening here?

And finally, let’s review the app.config.ts file:

import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient()
  ]
};

What’s happening here?

This configuration represents modern Angular’s approach to application setup. It’s more explicit, tree-shakeable, and performant than the traditional NgModule approach. The provideZonelessChangeDetection() is particularly interesting as it works seamlessly with our signal-based chat component, providing better performance without the overhead of Zone.js.

Step 8: Proxy to the API

I’m personally not a big fan of using CORS if not needed. For local development, this is definetely not needed and I consider a good practice not using CORS locally. For this reason, I prefer to always create a proxy.conf.js file in the project’s root folder:

// file path: angular-ai/proxy.conf.js
const PROXY_CONFIG = [
  {
    context: ["/api"],
    target: "http://localhost:8080/",
    secure: false,
    logLevel: "debug",
  },
];
module.exports = PROXY_CONFIG;

What’s happening here?

Why use a proxy instead of CORS?

To use this proxy, you’ll need to start your Angular development server with (package.json):

"start": "ng serve --proxy-config proxy.conf.js -o"

Use npm run start instead of ng serve directly.

Or add it to your angular.json file in the serve configuration:

"serve": {
  "builder": "@angular-devkit/build-angular:dev-server",
  "options": {
    "proxyConfig": "proxy.conf.js"
  }
}

This setup ensures that when your Angular app makes a request to /api/chat, it gets automatically forwarded to http://localhost:8080/api/chat where our Spring Boot backend is listening.

Testing the Application

Step 1: Start the Backend

Navigate to the api-ai directory and run:

./mvnw spring-boot:run

The backend should start on port 8080.

Step 2: Start the Frontend

In the angular-ai directory, run:

npm start

The Angular app will start on port 4200 and automatically open in your browser (-o parameter we added to the start script).

Step 3: Test the Chat

  1. Open your browser and go to http://localhost:4200
  2. Navigate to the Simple Chat section
  3. Type a message and press Enter or click Send
  4. You should see the AI’s response appear in the chat

Tip: Try asking questions like “What is Spring AI?” or “Explain dependency injection in Spring” to test the AI’s knowledge.

placeholder

Conclusion

Congratulations! 🎉 You’ve successfully built a simple AI chat application using Spring AI and Angular. This foundation gives you everything you need to create more sophisticated AI-powered applications.

In this tutorial, we covered:

What’s Next?

Happy coding! 🚀

Want the code? Go straight to GitHub: https://github.com/loiane/spring-ai-angular

References

All tutorials from this series

This tutorial is part of a series of articles. Read them all below:


Found this tutorial helpful? Share it with your fellow developers and let me know what you’d like to see next!