Angular 18 Tutorial: Build a Movies App with Angular 18, HttpClient and Tailwind
In this tutorial, we'll be using Angular 18, the latest version of Angular, the popular platform for building front-end web applications with TypeScript. The latest version, released on May 22, brings a set of new features and enhancements that we will leverage to build a robust movies application.
We'll be building a movie application with Angular 18 and API, showcasing different concepts such as working with standalone components, services, consuming a REST API with HttpClient, Observables and infinite scrolling and playing videos. We'll also cover the use of the async pipe and various other pipes like the date and currency pipes. By the end of this angular 18 tutorial, you will have a solid understanding of how to build dynamic and interactive web applications using Angular 18. We'll also be using Tailwind CSS for styling our UI.
Our Angular 18 application will have the following features:
- Three pages: Home, Movies and Show Movie pages.
- Navbar and footer standalone components.
- Three sections for movies: Popular, Top Rated, and Now Playing.
- Image slider: A movies slider.
- Scrolling functionality: Buttons to navigate through movie lists.
- Movie cards: Each movie will be displayed in a card format containing the title, release date, and rating.
- API Integration: Get movie data from an external API.
- Responsive UI: A clean and responsive user interface using Tailwind CSS.
- Infinite scrolling.
Let's start with the home page. Here is the top of the home page we'll be building in our angular 18 tutorial:
The top of the home page contains a movies slider that slides a set of movies displaying their images, titles, descriptions and release data.
Here is the bottom of the home page:
It contains three sections: Popular, Top and Now Playing sections. When we click on the buttons at the left and right sides we scroll horizontally through the movies. Each movie is displayed in a card format containing the title, release date and rating.
The second page is the movies pages with infinite scrolling where we scroll vertically through movies. Each movie is displayed in a card format containing the title, release date and rating:
When the user scrolls down to the bottom, more movies will be fetched by Angular HttpClient and displayed.
The last page is the Show Movie page that looks like this on top:
It contains images of the movie plus the title and overview and the actors. It also contains the Play Now button the plays a trailer video of the movie.
At the bottom, it looks like this:
It contains an horizontally scrolling standalone component of the similar movies.
All pages contains a navbar and footer. The navigation bar contains links to the home and movies pages.
Let's start our angular 18 tutorial.
Angular 18 Tutorial: Installing Angular CLI v18 & Creating a Project
To start building our movies application with Angular 18, we need to first install the Angular CLI (Command Line Interface) version 18. The Angular CLI is a powerful tool that helps in automating various tasks when developing Angular applications, such as creating new projects, generating components, and running tests.
Step 1: Install Node.js and npm
Ensure you have Node.js and npm (Node Package Manager) installed on your system. You can download and install them from the official Node.js website. Once installed, you can verify the installation by running the following commands in your terminal:
node -v
npm -v
These commands should display the versions of Node.js and npm installed on your system.
Step 2: Install Angular CLI
To install Angular CLI v18, open your terminal and run the following command:
npm i --global @angular/cli
The --global
flag installs the Angular CLI globally on your system, allowing you to use the ng
command from anywhere.
Step 3: Verify the Installation
After the installation is complete, verify that Angular CLI v18 is installed by running:
ng version
This command will display the installed Angular CLI version along with other related package versions.
Creating a New Angular 18 Project
With Angular CLI installed, we can now create a new Angular 18 project for our movies application.
Step 1: Create a New Project
Run the following command to create a new Angular project named movies-app
:
ng new movies-app
The CLI will prompt you to select various configuration options, such as which stylesheets format to use. For this angular 18 tutorial, choose the following options:
? Which stylesheet format would you like to use? CSS
? Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? No
Step 2: Navigate to the Project Directory
After the project is created, navigate to the project directory:
cd movies-app
Angular 18 Tutorial: Configuring Tailwind
Tailwind CSS is a utility-first CSS framework that allows you to build custom designs without leaving your HTML. In this section, we will configure Tailwind CSS in our Angular application to leverage its powerful styling capabilities.
First, we need to install Tailwind CSS along with PostCSS and Autoprefixer. Open your terminal and navigate to the root directory of your Angular project. Then, run the following commands:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
The npx tailwindcss init
command creates a tailwind.config.js
file, which is used to configure Tailwind CSS.
Open tailwind.config.js
and add the paths to all of your template files. Inside the content
array, add:
"./src/**/*.{html,ts}",
This configuration tells Tailwind CSS to remove any unused styles in production by scanning all .html
and .ts
files in the src
directory.
We need to import Tailwind CSS into our global styles file. Open the src/styles.css
file and add the following lines:
@tailwind base;
@tailwind components;
@tailwind utilities;
These directives include Tailwind's base styles, component classes, and utility classes into your project.
To ensure that Tailwind CSS is working correctly, let's add some Tailwind classes to our app.component.html
file. Open src/app/app.component.html
and replace its content with the following:
<div class="flex w-full h-screen items-center justify-center bg-slate-600">
<h1 class="text-3xl font-bold">Hello Tailwind!</h1>
</div>
<router-outlet></router-outlet>
This code snippet uses Tailwind utility classes to style the page, setting a minimum height for the screen, centering content both vertically and horizontally, applying a background color, and styling the text.
Now, start the development server to see the changes:
ng serve
Open your browser and navigate to http://localhost:4200/
. You should see a styled page with the message "Hello Tailwind!":
At this point, we have successfully installed Angular CLI v18, created a new Angular project named movies-app
and installed and configured Tailwind CSS in our Angular 18 application. We've also verified that Tailwind CSS is working by adding some utility classes to our app.component.html
file. Tailwind CSS is now ready to be used for styling the rest of our movies application.
Next, we will proceed with building the core components and integrating the movies API to fetch and display movie data. Here's an overview of what we'll cover:
- Creating Components: We'll create reusable Angular standalone components for the navbar, footer, and movie cards.
- Setting Up Services: We'll set up an Angular service to handle API calls for fetching movie data.
- Building the Home Page: We'll build the home page, which includes a movie slider and sections for Popular, Top Rated, and Now Playing movies.
- Implementing the Movies Page: We'll implement the movies page with infinite scrolling to load more movies as the user scrolls down.
- Creating the Show Movie Page: We'll create a detailed view for individual movies.
In the next steps, we will start building the standalone components and services for our movies application, integrating it with an external API to fetch movie data, and adding the necessary functionality to display and interact with the movie data.
Adding & styling the navigation bar component with Tailwind CSS
To enhance the navigation experience in our movies application, we'll create a reusable standalone Navbar component. This component will be added to our main application component to ensure it appears on every page.
Using Angular CLI, generate a new standalone component called navbar
:
ng g c components/navbar
This command creates the following files in the src/app/components/navbar
directory:
-
navbar.component.ts
-
navbar.component.html
-
navbar.component.css
-
navbar.component.spec.ts
Next, we need to add the navbar component to the app standalone component. First, in the src/app/app.component.ts
file, import NavbarComponent
and add it the imports
array:
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NavbarComponent } from './components/navbar/navbar.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, NavbarComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'moviesapp';
}
Next, in the src/app/app.component.html
include the component:
<div class="bg-black">
<app-navbar></app-navbar>
<router-outlet></router-outlet>
</div>
Open the src/app/components/navbar/navbar.component.ts
file and import the router module:
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [RouterModule],
templateUrl: './navbar.component.html',
styleUrl: './navbar.component.css'
})
export class NavbarComponent {}
Next, we'll define the layout for our navigation bar. Open the src/app/components/navbar/navbar.component.html
file and add the following HTML:
<nav class="fixed top-0 w-full h-12 flex bg-slate-700 text-white justify-between z-50">
<div class="flex items-center gap-10 px-10">
<img class="w-9" src="logo.svg" />
<a routerLink="/">Home</a>
<a routerLink="/movies">Movies</a>
</div>
</nav>
This <nav>
element creates a fixed navigation bar at the top of the viewport. The bar has a dark slate background and white text. It uses Flexbox for layout, ensuring the logo and navigation links are evenly spaced and centered vertically within the bar. The navigation bar remains on top of other content due to the z-50
z-index setting.
By structuring the <nav>
element this way, we create a responsive and visually appealing navigation bar that enhances the user experience on our Angular application.
This is how it looks:
Just like we did with the navbar component, generate the footer component and include it in the app component below the router outlet:
<div class="bg-black">
<app-navbar></app-navbar>
<router-outlet></router-outlet>
<app-footer></app-footer>
</div>
In the src/app/components/footer/footer.component.html
add the following HTML code:
<footer>
<p class="bg-slate-700 text-white text-center mt-20">
Created by Techiediaries
</p>
</footer>
Creating Angular 18 Routing Components
To build our movies application, we'll need different pages to display various sections like Home, Movies, and a detailed Show Movie page. We'll achieve this by creating routing components and setting up routes in Angular.
In the terminal, run the following commands:
ng g c pages/home
ng g c pages/movies
ng g c pages/show-movie
Open the src/app/app.routes.ts
file and add the routes to different components:
import { Routes } from '@angular/router';
import { HomeComponent } from './pages/home/home.component';
import { MoviesComponent } from './pages/movies/movies.component';
import { ShowMovieComponent } from './pages/show-movie/show-movie.component';
export const routes: Routes = [
{
path: '',
component: HomeComponent,
pathMatch: 'full',
},
{
path: 'movies',
component: MoviesComponent,
},
{
path: 'show-movie/:movieId',
component: ShowMovieComponent,
},
];
The routes
array contains route configurations that define how different paths in our Angular application should be handled. Each route configuration specifies a path and the component that should be displayed when that path is accessed. Dynamic route parameters can also be used to create more flexible routes that handle varying data. With this route configuration, our application knows how to navigate to different views based on the URL.
The first route configuration specifies that when the root path (''
) is accessed, the HomeComponent
should be displayed. The pathMatch: 'full'
ensures that the route matches only when the entire URL matches the empty string.
The second route configuration specifies that when the /movies
path is accessed, the MoviesComponent
should be displayed.
The last route configuration specifies a dynamic route parameter :movieId
within the path /show-movie/:movieId
. When a URL matching this pattern is accessed (e.g., /show-movie/123
), the ShowMovieComponent
is displayed. The movieId
parameter can be accessed within the component to fetch details for the specific movie.
Ensuring Type Safety of our Angular 18 App
To ensure type safety and clarity in our Angular application, we'll define interfaces for the data structures we expect from our movie API. Interfaces help us define the shape of data objects and make our code more readable and maintainable. We'll create interfaces for movies, movie lists, credits, actors, images, and videos.
Create a src/app/models/movie.ts
file and add the following interface for a movie:
export interface Movie {
id: number;
adult: boolean;
backdrop_path: string;
genre_ids: number[];
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
revenue?: number;
runtime?: string;
status?: string;
genres?: any[];
}
This interface outlines the structure of a movie object, including optional properties like revenue
, runtime
, status
, and genres
.
Next, add the movies interface:
export interface Movies {
page: number;
results: Movie[];
total_pages: number;
total_results: number;
}
The Movies
interface includes pagination information and an array of Movie
objects.
Next, create a src/app/models/credit.ts
file and add interface for movie credits:
export interface Credits {
cast: Actor[];
}
This interface represents the credits of a movie, containing an array of Actor
objects.
Next, add an interface for movie actors:
export interface Actor {
name: string;
profile_path: string;
character: string;
id: number;
}
The Actor
interface describes an actor's basic details, including their name, profile picture path, character they played, and ID.
Next, create a src/app/models/image.ts
file and add the following interfaces:
export interface Images {
backdrops: Image[];
}
export interface Image {
file_path: string;
}
The Images
interface includes an array of Image
objects, and the Image
interface represents an individual image with a file path.
Next, create a src/app/models/video.ts
file and add:
export interface Videos {
results: Video[];
id: string;
}
export interface Video {
key: string;
site: string;
}
The Videos
interface includes an array of Video
objects, and the Video
interface describes a video's key and the site where it's hosted.
Adding a Movies API Angular 18 Service
To fetch movie data from an external API and manage API interactions efficiently, we'll create a service in Angular. This service will use HttpClient
to make HTTP requests to a movie database API.
Step 1: Generate the Movies Service
First, head to a terminal and run the following command to generate a new service:
ng g s services/movies
This command creates a new service file (movies.service.ts
) in the src/app/services
directory.
Step 2: Generate Environment Variables
Next, generate the environment variables files using the Angular CLI:
ng g environments
This command creates environment configuration files in the src/environments
directory.
Step 3: Add API Key to Environment Variables
Open the src/environments/environment.development.ts
file and add your API key for accessing the movie database:
export const environment = {
apiKEY: 'dadb019730c0075868955d1ec94040bb'
};
Make sure to replace dadb019730c0075868955d1ec94040bb
with your own API key.
Step 4: Provide HttpClient in App Configuration
To use HttpClient
in your Angular 18 application, you need to provide it in your application's configuration. Open the src/app/app.config.ts
file and add the following code:
// [...]
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [..., provideHttpClient()]
};
Step 5: Implement the Movies Service
Now, let's implement the methods to fetch movie data in the MoviesService
. Open the src/app/services/movies.service.ts
file and start by adding the following imports:
import { Injectable, inject } from '@angular/core';
import { environment } from '../../environments/environment';
import { HttpClient } from '@angular/common/http';
import { Movie, Movies } from '../models/movie';
import { map } from 'rxjs';
import { Videos } from '../models/video';
import { Credits } from '../models/credit';
-
inject
for injecting services. -
environment
: Imports environment variables where API key is stored. -
HttpClient
: Used to make HTTP requests to the API. -
Movie
,Movies
,Videos
,Credits
: Interfaces defining the structure of the data returned by the API.
Defining Base URL for Movie Images
Next, define and export the base URL for fetching movie images from the API.
export const imagesBaseUrl = 'https://image.tmdb.org/t/p/';
Initializing Variables and Injecting HttpClient
Next, define the following variables and inject HttpClient:
export class MoviesService {
private apiUrl = 'https://api.themoviedb.org/3';
private apiKey = environment.apiKEY;
private httpClient = inject(HttpClient);
constructor() { }
This initializes private properties for API URL, API key, and an instance of HttpClient
.
Getting Movies by Type
Next, define the fetchMoviesByType
method:
fetchMoviesByType(type: string, pageNumber = 1) {
return this.httpClient
.get<Movies>(`${this.apiUrl}/movie/${type}?page=${pageNumber}&api_key=${this.apiKey}`)
}
This method will be used to fetch movies of a specific type (e.g., popular, top-rated) from the API. It accepts type
(e.g., 'popular', 'top_rated') and pageNumber
as parameters and constructs the API URL with the specified type and page number and makes an HTTP GET request using HttpClient
.
Getting Similar Movies
Next, define the fetchSimilarMovies
method:
fetchSimilarMovies(id: string) {
return this.httpClient
.get<Movies>(
`${this.apiUrl}/movie/${id}/similar?api_key=${this.apiKey}`
)
.pipe(map((data)=> data.results));
}
This method will be used to fetch movies similar to a specified movie by its ID. It constructs the API URL for similar movies and makes an HTTP GET request. It uses pipe
and map
operators from RxJS to extract the results
property from the API response.
Getting Movie by ID
Next, define the fetchMovieById
method:
fetchMovieById(id: string) {
return this.httpClient.get<Movie>(
`${this.apiUrl}/movie/${id}?api_key=${this.apiKey}`
)
}
This method will be used to fetch details of a specific movie by its ID. It constructs the API URL for the movie details and makes an HTTP GET request.
Getting Movie Videos
Next, define the fetchMovieVideos
method:
fetchMovieVideos(id: string) {
return this.httpClient
.get<Videos>(
`${this.apiUrl}/movie/${id}/videos?api_key=${this.apiKey}`
)
.pipe(map((data) => data.results))
}
This method will be used to fetch videos (trailers, teasers) associated with a specific movie by its ID. It constructs the API URL for movie videos and makes an HTTP GET request. It uses pipe
and map
operators to extract the results
property from the API response.
Getting Movie Cast
Finally, define the fetchMovieCast
method:
fetchMovieCast(id: string) {
return this.httpClient
.get<Credits>(
`${this.apiUrl}/movie/${id}/credits?api_key=${this.apiKey}`
)
.pipe(map((data) => data.cast))
}
This method will be used to fetch the cast (actors) of a specific movie by its ID. It constructs the API URL for movie credits and makes an HTTP GET request. It uses pipe
and map
operators to extract the cast
property from the API response.
This MoviesService
provides methods to fetch various movie-related data from an external API. It encapsulates the logic for making HTTP requests and handling API responses, allowing other parts of the application to easily consume and utilize movie data.
Summary
We've successfully set up a Movies API service in Angular 18 by following these steps:
- Generated the Movies Service: Created a service using Angular CLI v18.
- Configured Environment Variables: Added the API key in the environment configuration file.
- Provided HttpClient: Configured
HttpClient
in the application's configuration. - Implemented API Methods: Added methods in the
MoviesService
to fetch popular, top-rated, and now-playing movies, as well as movie details, credits, images, and videos.
With this setup, our Angular 18 application can now interact with the movie database API to fetch and display movie data.
Implementing the Movie Component
The MovieComponent
is an Angular standalone component responsible for rendering the visual representation of a single movie. It receives movie data through its movie
input property and utilizes Angular's template and styling capabilities to present the movie information to the user.
Open a terminal and run the following command:
ng g c components/movie
Open the src/app/components/movie/movie.component.ts
file and add the following imports:
import { Input } from '@angular/core';
import { Movie } from '../../models/movie';
import { DatePipe } from '@angular/common';
import { imagesBaseUrl } from '../../services/movies.service';
import { RouterModule } from '@angular/router';
Next, add the date pipe, and router module to the imports
array of the standalone component:
@Component({
selector: 'app-movie',
standalone: true,
imports: [DatePipe, RouterModule],
templateUrl: './movie.component.html',
styleUrl: './movie.component.css'
})
Next, define a public property imagesBaseUrl
and an input property named movie
of type Movie
:
export class MovieComponent {
public imagesBaseUrl = imagesBaseUrl;
@Input() movie!: Movie;
}
The @Input()
decorator indicates that the property can receive data from its parent component. The !
symbol after movie
is TypeScript's non-null assertion operator, which tells TypeScript that movie
will be initialized by the parent component and won't be null or undefined.
For the component template, add the following HTML:
<a
routerLink="/show-movie/{{ movie.id }}"
class="w-full min-w-[230px] h-90 overflow-hidden block rounded relative hover:scale-105 transition-all">
@if(movie.poster_path){
<img
class="w-full"
[src]="imagesBaseUrl + '/w185/' + movie.poster_path"
[alt]="movie.title"
/>
}
<div class="absolute bottom-0 h-30 bg-black/60 p-2 w-full text-white">
<h2 class="text-ellipsis text-lg font-semibold">
{{ movie.title }}
</h2>
<div class="flex justify-between bottom-0">
<p>
{{ movie.release_date | date }}
</p>
<p class="bg-black text-white rounded m-0 text-sm">
Rating: {{ movie.vote_average }}
</p>
</div>
</div>
</a>
This component allows users to click on a movie poster to view its details. It dynamically populates movie data and handles conditional rendering of the poster image. Overall, it provides a visually appealing and interactive way to browse and explore movies.
This is how it should look like:
Implementing the Movies Component with Infinite Scrolling
The movies page with infinite scrolling provides users with a convenient and engaging way to explore a large collection of movies, presenting essential details in an aesthetically pleasing card format while offering seamless navigation and responsive design.
The page dynamically loads additional movie cards as the user scrolls down, providing a seamless browsing experience without the need for pagination.
Go back to your terminal and run the following command:
npm install ngx-infinite-scroll --save
Next, open the src/app/pages/movies/movies.component.ts
file and start by adding the following imports:
import { Component, DestroyRef, inject } from '@angular/core';
import { MoviesService } from '../../services/movies.service';
import { AsyncPipe } from '@angular/common';
import { Movie } from '../../models/movie';
import { MovieComponent } from '../../components/movie/movie.component';
import { InfiniteScrollModule } from "ngx-infinite-scroll";
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
The import statements in the Angular component file provide essential dependencies for building a functional component. They include the @Component
decorator for marking components, the movies service for data fetching, the async pipe for handling asynchronous data, the Movie interface type safety, the movie component for visual representation, and a module for additional features like infinite scrolling. These imports collectively enable the component to interact with data, implement UI features, and manage resources effectively.
Next, add the async pipe, movie component and infinite scroll module to the imports
array of the standalone component:
@Component({
selector: 'app-movies',
standalone: true,
imports: [AsyncPipe, MovieComponent, InfiniteScrollModule],
templateUrl: './movies.component.html',
styleUrl: './movies.component.css'
})
Next, inject the movies and DestroyRef
services and call the fetchMoviesByType
method as follows:
private moviesService = inject(MoviesService);
private pageNumber = 1;
private destroyRef = inject(DestroyRef)
public moviesObs$ = this.moviesService.fetchMoviesByType('popular', this.pageNumber);
public moviesResults: Movie[] = [];
These initialize properties within the component's class, including a service instance for fetching movies, a page number, and a resource for managing component destruction. It also defines an observable property for storing fetched movie data and an array for storing the results.
Next, in the ngOnInit()
life-cycle method, subscribe to the movies observable and assign the results to the moviesResults
array:
ngOnInit(){
this.moviesObs$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => {
this.moviesResults = data.results;
});
}
In the ngOnInit
lifecycle hook, we're subscribing to the moviesObs$
observable to get movie data and update the moviesResults
property when data is received. We use the pipe
operator to chain operators onto the observable. The takeUntilDestroyed
operator is used here to automatically unsubscribe from the observable when the component is destroyed, preventing memory leaks.
Overall, this ensures that movie data is fetched when the component is initialized, and the moviesResults
property is updated accordingly. The use of takeUntilDestroyed
ensures that subscriptions are properly cleaned up when the component is destroyed, preventing memory leaks.
Next, define the onScroll()
method which gets called when the user scrolls to the bottom:
onScroll(): void {
this.pageNumber++;
this.moviesObs$ = this.moviesService.fetchMoviesByType('popular', this.pageNumber);
this.moviesObs$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => {
this.moviesResults = this.moviesResults.concat(data.results);
});
}
This function helps with the continuous loading of movie data as the user scrolls down the page, providing a seamless browsing experience. It ensures that new movie data is fetched and added to the existing movies array without losing previously fetched data.
It increments the pageNumber
property, indicating that the next page of data should be fetched. Then, fetches movies for the next page using the fetchMoviesByType
method of the moviesService
. It subscribes to the returned observable to handle the emitted movie data. It also uses takeUntilDestroyed
to ensure the subscription is cleaned up properly when the component is destroyed. Within the subscription callback, the new movie results are concatenated with the existing moviesResults
array. This ensures that the new movie data is appended to the existing list, preserving previously fetched data.
In the component template movies.component.html
, add the following HTML:
<div infiniteScroll (scrolled)="onScroll()" class="container mx-auto pt-20">
<div class="grid grid-cols-[repeat(auto-fit,230px)] gap-5 justify-center">
@for(movie of moviesResults; track movie.id){
<app-movie [movie]="movie"></app-movie>
}
</div>
</div>
This code adds a movies container with an infinite scroll feature and a grid layout to display movie cards. The container div
element uses the infiniteScroll
directive to enable infinite scrolling functionality. The (scrolled)
event is bound to the onScroll()
method, which is triggered when the user scrolls. Using the @for
syntax, it loops through each movie
in the moviesResults
array, tracking the id
of each movie for efficient rendering and renders the movie component while binds the movie
object to the movie
input property of the app-movie
component, passing the movie data for rendering.
With these changes, our movies page should now have infinite scrolling functionality, allowing users to scroll through a large collection of movies seamlessly. New movies will be fetched and appended to the existing list as the user scrolls down the page.
You can implement the other components in the same way. If you are stuck, take a look the code from this repository.
Conclusion
Throughout this angular 18 tutorial, we've created a movies application with Angular 18, Tailwind CSS, HttpClient and an external REST API.
-
Date: