Building a Simple AI Chat Application with Spring AI and Angular

tag
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!

This site uses cookies. Please choose whether to accept analytics cookies. Privacy Policy