DEV Community

Build an Angular + Spring Boot App From Scratch (and Fix the Errors You'll Hit)

Build an Angular + Spring Boot App From Scratch (and Fix the Errors You'll Hit)

The first time I wired Angular to a Spring Boot backend, the tutorial worked fine, and then I changed one thing and spent an evening staring at a CORS error. Most guides show the happy path. This one includes the parts where things break.

We'll build a small task manager with a Spring Boot REST API and an Angular frontend. It's small enough to finish in an afternoon and structured like a real project: validation, pagination, error handling, a dev proxy, and a few performance habits.

What you need:

  • Java 17+ and Maven
  • Node 18+ and the Angular CLI (npm i -g @angular/cli)
  • Basic Java and TypeScript. You don't need to know either framework.

I'm using Spring Boot 3.x and a recent Angular with standalone components. If you're on older versions, the ideas hold, but some syntax will differ.

The plan

task-manager/
├── backend/     # Spring Boot (port 8080)
└── frontend/    # Angular (port 4200)
Enter fullscreen mode Exit fullscreen mode

Two folders, one Git repo. Angular talks to Spring Boot over REST/JSON. That's the whole architecture.

Part 1: The Spring Boot backend

Generate a project at start.spring.io with Maven, Java 17, and these dependencies: Spring Web, Spring Data JPA, Validation, H2 Database. Unzip it into backend/.

H2 keeps setup at zero for now. Swapping to PostgreSQL later is a config change, and I'll show that in the best-practices section.

The entity

package com.example.tasks;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 120)
    private String title;

    private boolean done;

    private LocalDateTime createdAt = LocalDateTime.now();

    protected Task() {} // required by JPA

    public Task(String title) {
        this.title = title;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public boolean isDone() { return done; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void toggle() { this.done = !this.done; }
}
Enter fullscreen mode Exit fullscreen mode

I left out setters on purpose. The only state change is toggle(), which is one less way to end up with a half-updated object.

The repository

package com.example.tasks;

import org.springframework.data.jpa.repository.JpaRepository;

public interface TaskRepository extends JpaRepository<Task, Long> {}
Enter fullscreen mode Exit fullscreen mode

That's the entire data layer. JpaRepository gives you save, findById, findAll(Pageable), and deleteById.

A request DTO

Don't accept your entity directly from the client. A tiny record is enough:

package com.example.tasks;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record TaskRequest(
    @NotBlank(message = "Title is required")
    @Size(max = 120, message = "Title must be 120 characters or fewer")
    String title
) {}
Enter fullscreen mode Exit fullscreen mode

The controller

package com.example.tasks;

import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskRepository repo;

    public TaskController(TaskRepository repo) {
        this.repo = repo;
    }

    @GetMapping
    public Page<Task> list(
        @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {
        return repo.findAll(pageable);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Task create(@Valid @RequestBody TaskRequest req) {
        return repo.save(new Task(req.title()));
    }

    @PatchMapping("/{id}/toggle")
    public ResponseEntity<Task> toggle(@PathVariable Long id) {
        return repo.findById(id)
            .map(task -> {
                task.toggle();
                return ResponseEntity.ok(repo.save(task));
            })
            .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (!repo.existsById(id)) return ResponseEntity.notFound().build();
        repo.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Clean validation errors

Without this, a blank title gives the client a noisy default error blob. Add a small handler:

package com.example.tasks;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.stream.Collectors;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        return ex.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                f -> f.getField(),
                f -> f.getDefaultMessage(),
                (a, b) -> a));
    }
}
Enter fullscreen mode Exit fullscreen mode

Run and test

cd backend
./mvnw spring-boot:run
Enter fullscreen mode Exit fullscreen mode

In another terminal:

curl -X POST localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Spring Boot"}'

curl "localhost:8080/api/tasks?page=0&size=5"
curl -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":""}'
Enter fullscreen mode Exit fullscreen mode

The last one should return a 400 with {"title":"Title is required"}. Test the backend with curl or Postman before you touch Angular. When something breaks later, you'll know which side it's on.

Part 2: The Angular frontend

ng new frontend --routing=false --style=css
cd frontend
Enter fullscreen mode Exit fullscreen mode

Set up the dev proxy

This is the step most tutorials skip and then fix with CORS hacks. Create proxy.conf.json next to package.json:

{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false
  }
}
Enter fullscreen mode Exit fullscreen mode

Then run the dev server with it:

ng serve --proxy-config proxy.conf.json
Enter fullscreen mode Exit fullscreen mode

Now Angular calls /api/tasks on its own origin (localhost:4200), and the dev server forwards it to Spring Boot. The browser sees one origin, so there's no CORS problem in development.

Enable HttpClient

In src/app/app.config.ts:

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()],
};
Enter fullscreen mode Exit fullscreen mode

Forgetting this gives you NullInjectorException: No provider for HttpClient. I've done it more than once.

The model and service

src/app/task.service.ts:

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Task {
  id: number;
  title: string;
  done: boolean;
  createdAt: string;
}

export interface Page<T> {
  content: T[];
  totalElements: number;
  totalPages: number;
  number: number;
}

@Injectable({ providedIn: 'root' })
export class TaskService {
  private http = inject(HttpClient);
  private base = '/api/tasks';

  list(page = 0, size = 10): Observable<Page<Task>> {
    return this.http.get<Page<Task>>(this.base, { params: { page, size } });
  }

  create(title: string): Observable<Task> {
    return this.http.post<Task>(this.base, { title });
  }

  toggle(id: number): Observable<Task> {
    return this.http.patch<Task>(`${this.base}/${id}/toggle`, {});
  }

  remove(id: number): Observable<void> {
    return this.http.delete<void>(this.base + '/' + id);
  }
}
Enter fullscreen mode Exit fullscreen mode

The component

Replace src/app/app.component.ts (rename the file if your CLI version generated app.ts):

import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TaskService, Task } from './task.service';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [FormsModule],
  template: `
    <main class="wrap">
      <h1>Tasks</h1>

      <form (ngSubmit)="add()">
        <input [(ngModel)]="title" name="title" placeholder="What needs doing?" />
        <button type="submit" [disabled]="!title.trim()">Add</button>
      </form>

      @if (error()) {
        <p class="error">{{ error() }}</p>
      }

      <ul>
        @for (t of tasks(); track t.id) {
          <li>
            <label>
              <input type="checkbox" [checked]="t.done" (change)="toggle(t)" />
              <span [class.done]="t.done">{{ t.title }}</span>
            </label>
            <button (click)="remove(t)">✕</button>
          </li>
        } @empty {
          <li>Nothing here yet.</li>
        }
      </ul>

      <p>{{ total() }} task(s) total</p>
    </main>
  `,
  styles: [`
    .wrap { max-width: 480px; margin: 2rem auto; font-family: system-ui; }
    .done { text-decoration: line-through; opacity: .6; }
    .error { color: #b00020; }
    li { display: flex; justify-content: space-between; padding: .25rem 0; }
  `],
})
export class AppComponent implements OnInit {
  private api = inject(TaskService);

  tasks = signal<Task[]>([]);
  total = signal(0);
  error = signal('');
  title = '';

  ngOnInit() { this.load(); }

  load() {
    this.api.list().subscribe({
      next: p => { this.tasks.set(p.content); this.total.set(p.totalElements); },
      error: () => this.error.set('Could not load tasks. Is the backend running?'),
    });
  }

  add() {
    this.error.set('');
    this.api.create(this.title.trim()).subscribe({
      next: () => { this.title = ''; this.load(); },
      error: e => this.error.set(e.error?.title ?? 'Something went wrong'),
    });
  }

  toggle(t: Task) {
    this.api.toggle(t.id).subscribe(() => this.load());
  }

  remove(t: Task) {
    this.api.remove(t.id).subscribe(() => this.load());
  }
}
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:4200. Add a task, tick it off, delete it. That's the full loop from Angular to Spring Boot to the database and back.

Practical exercises

Working through the tutorial isn't the same as learning it. Try these without copy-pasting from anywhere:

  1. Edit a task title. Add PUT /api/tasks/{id} with validation, plus an inline edit in the UI.
  2. Real pagination. The backend already supports it. Add Previous/Next buttons using number and totalPages.
  3. Filter by status. Add a derived query, Page<Task> findByDone(boolean done, Pageable p), and a dropdown.
  4. Swap H2 for PostgreSQL (config below) and confirm data survives a restart.
  5. Write two tests: a @WebMvcTest for the validation path and a Karma/Jest test for TaskService with HttpTestingController.

Exercise 5 is the one people skip and the one that teaches the most.

Troubleshooting and common errors

1. Access to XMLHttpRequest has been blocked by CORS policy
You're calling http://localhost:8080 directly from the browser. Use the proxy above and call relative URLs. If you do need CORS (say, a deployed frontend on a different domain), configure it explicitly:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://your-frontend.example.com")
                .allowedMethods("GET", "POST", "PATCH", "DELETE");
    }
}
Enter fullscreen mode Exit fullscreen mode

Avoid allowedOrigins("*") outside of throwaway experiments.

2. The proxy seems ignored
proxy.conf.json only applies if you start the server with --proxy-config, or set it under serve in angular.json. Restart ng serve after editing it, because it isn't hot-reloaded.

3. NullInjectorError: No provider for HttpClient
Missing provideHttpClient() (standalone) or HttpClientModule (module-based apps).

4. 415 Unsupported Media Type
The request is missing Content-Type: application/json. Angular sets it for object bodies. curl doesn't.

5. 400 on POST with a valid-looking body
Check the JSON key against the record component. {"name": "x"} won't bind to title. Also make sure @Valid is on the parameter.

6. Spring Data warning about serializing PageImpl
Newer Spring Data versions warn that returning Page directly isn't a stable JSON contract. Fix it with @EnableSpringDataWebSupport(pageSerializationMode = PageSerializationMode.VIA_DTO) on a config class, or return your own response record. If your JSON shape changes after a Boot upgrade, this is a likely cause.

7. Port 8080 was already in use
An old process is still running. On macOS/Linux: lsof -i :8080. On Windows: netstat -ano | findstr 8080. Or set server.port=8081 and update the proxy target.

8. Data disappears on restart
That's H2 in-memory doing its job. Use a file or a real database.

9. Angular change doesn't show up
Hard-refresh, and check whether the tab is running a stale service worker or a cached build.

Best practices

Keep the API contract explicit. Version your paths (/api/v1/...) once a second consumer exists. Never return raw entities long-term; map to response DTOs so your schema can change without breaking the UI.

Validate on both sides, trust only one. Angular validation is for user experience. Bean Validation on the server is the actual rule.

Use profiles for config. Keep secrets out of Git:

# application-prod.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/tasks
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
Enter fullscreen mode Exit fullscreen mode

Add the org.postgresql:postgresql dependency, then run with --spring.profiles.active=prod. Use ddl-auto=validate (or nothing) in production and manage schema changes with Flyway or Liquibase. Auto-generating tables is fine on your laptop and risky anywhere else.

Handle errors in one place. In Angular, an HttpInterceptor can attach auth headers and turn failures into user-friendly messages. Spring already has your @RestControllerAdvice.

Environment-based API URLs. In production, Angular is usually served from a static host or behind Nginx, and the proxy config doesn't exist there. Route /api to the backend at the reverse proxy level and keep the relative URLs.

Don't put business logic in controllers. Once rules grow beyond "save this," move them into a @Service. It makes testing far easier.

Performance tips

These are the cheap wins I'd apply before reaching for anything clever.

  • Always paginate lists. findAll() on a table that grows will eventually hurt. We used Pageable from the start.
  • Watch for N+1 queries. Turn on spring.jpa.show-sql=true in dev and look at what a single request triggers. If you add relationships, use JOIN FETCH or @EntityGraph.
  • Index what you filter on. If you add findByDone, index that column when the table gets large.
  • Return only what the UI needs. Projections or DTOs beat dragging whole entities across the wire.
  • Angular: track in @for (or trackBy in older syntax) so the DOM isn't rebuilt on each refresh.
  • Avoid reloading the list after every action. I did that above for simplicity. For a real app, update the local state from the response and only refetch when needed.
  • Production build: ng build applies AOT, tree-shaking, and minification. Never benchmark with ng serve.
  • Enable compression (server.compression.enabled=true) or let your reverse proxy handle gzip/brotli.

GitHub examples worth reading

I'd rather point you at real code than have you trust my snippets alone:

  • spring-projects/spring-petclinic: the reference Spring Boot app, worth skimming for structure and testing style.
  • spring-petclinic/spring-petclinic-angular: an Angular frontend built against a Petclinic REST backend, a good look at a larger Angular + Spring pairing.
  • angular/angular and spring-projects/spring-boot: the official repos. The docs folders and issue trackers are good for figuring out why something changed.

When you push your own version, add a README with run instructions, a screenshot, and the curl commands from this article. A repo someone can run in two minutes tells a reader more than a long feature list.

Learning resources

  • The official Angular docs and the interactive tutorials at angular.dev
  • Spring Boot reference documentation and the "Building a RESTful Web Service" guide at spring.io/guides
  • Baeldung for focused Spring articles when you hit a specific problem
  • Hibernate/JPA docs once you start modeling relationships
  • The RxJS docs on switchMap, debounceTime, and catchError. Search-as-you-type is a good next exercise.

Where to go from here

Once this works, the next steps are auth (Spring Security with JWT or sessions), Docker Compose to run frontend, backend, and Postgres together, and a CI pipeline that runs both test suites.

If you're learning this stack and comparing options, whether that's self-study or looking at a java full stack training in bangalore, the useful question is what you'll have built by the end. Some people also look for a java full stack course in bangalore with placement support, and it's worth checking what that means in practice: real projects, code review, and interview practice count for more than a certificate. Whichever route you take, a repo like this one, with tests, a README, and a deployment, is what you can show an interviewer.

Wrapping up

Recap of what we covered:

  • A Spring Boot REST API with validation, pagination, and clean error responses
  • An Angular client with a typed service, signals, and a dev proxy
  • The errors you'll almost certainly hit, and what causes them
  • A handful of practical improvements to try next

If you get stuck on one of the exercises or hit an error I didn't list, drop it in the comments with your Angular and Spring Boot versions and the exact error message. Those two details solve most of it.

Happy building.


That comes out around 2,400 words, inside your range. I kept the keywords to one natural mention in the closing section so the tutorial doesn't read as promotional, and I didn't invent any GitHub repo links. If you'd like, I can put this into a file, or add a repo link once you've pushed your own version.

Top comments (0)