Building a URL Shortener App with Angular and Tailwind CSS

Manthan Ankolekar - Jul 26 - - Dev Community

In this blog, we will walk you through the process of creating a URL shortener application using Angular for the frontend and Tailwind CSS for styling. A URL shortener is a handy tool that converts long URLs into shorter, more manageable links. This project will help you understand how to build a functional and aesthetically pleasing web application using modern web development technologies.

Prerequisites

To follow along with this tutorial, you should have a basic understanding of Angular and some familiarity with Tailwind CSS. Ensure you have Node.js and Angular CLI installed on your machine.

Project Setup

1. Creating a New Angular Project

First, create a new Angular project by running the following command in your terminal:

ng new url-shortener-app
cd url-shortener-app
Enter fullscreen mode Exit fullscreen mode

2. Installing Tailwind CSS

Next, set up Tailwind CSS in your Angular project. Install Tailwind CSS and its dependencies via npm:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

Configure Tailwind CSS by updating the tailwind.config.js file:

module.exports = {
  content: [
    "./src/**/*.{html,ts}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

Add the Tailwind directives to your src/styles.scss file:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Building the URL Shortener

3. Creating the URL Model

Create a URL model to define the structure of the URL data. Add a new file src/app/models/url.model.ts:

export type Urls = Url[];

export interface Url {
  _id: string;
  originalUrl: string;
  shortUrl: string;
  clicks: number;
  expirationDate: string;
  createdAt: string;
  __v: number;
}
Enter fullscreen mode Exit fullscreen mode

4. Setting Up the URL Service

Create a service to handle API calls related to URL shortening. Add a new file src/app/services/url.service.ts:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Url, Urls } from '../models/url.model';
import { environment } from '../../environments/environment';

@Injectable({
  providedIn: 'root',
})
export class UrlService {
  private apiUrl = environment.apiUrl;

  constructor(private http: HttpClient) {}

  shortenUrl(originalUrl: string): Observable<Url> {
    return this.http.post<Url>(`${this.apiUrl}/shorten`, { originalUrl });
  }

  getAllUrls(): Observable<Urls> {
    return this.http.get<Urls>(`${this.apiUrl}/urls`);
  }

  getDetails(id: string): Observable<Url> {
    return this.http.get<Url>(`${this.apiUrl}/details/${id}`);
  }

  deleteUrl(id: string): Observable<Url> {
    return this.http.delete<Url>(`${this.apiUrl}/delete/${id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Creating the Shorten URL Component

Generate a new component for shortening URLs:

ng generate component shorten
Enter fullscreen mode Exit fullscreen mode

Update the component's HTML (src/app/shorten/shorten.component.html) to as shown below:

<div class="max-w-md mx-auto p-4 shadow-lg rounded-lg mt-4">
    <h2 class="text-2xl font-bold mb-2">URL Shortener</h2>
    <form [formGroup]="urlForm" (ngSubmit)="shortenUrl()">
        <div class="flex items-center mb-2">
            <input class="flex-1 p-2 border border-gray-300 rounded mr-4" formControlName="originalUrl"
                placeholder="Enter your URL" required />
            <button class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" type="submit">
                Shorten
            </button>
        </div>
        @if (urlForm.get('originalUrl')?.invalid && (urlForm.get('originalUrl')?.dirty ||
        urlForm.get('originalUrl')?.touched)) {
        <div class="text-red-500" role="alert" aria-live="assertive">
            @if (urlForm.get('originalUrl')?.errors?.['required']) {
            URL is required.
            }
            @if (urlForm.get('originalUrl')?.errors?.['pattern']) {
            Invalid URL format. Please enter a valid URL starting with http:// or https://.
            }
        </div>
        }
    </form>
    @if (errorMsg()) {
    <div class="p-4 bg-red-100 rounded mt-4">
        <p class="text-red-500">{{ errorMsg() }}</p>
    </div>
    }
    @if (shortUrl()) {
    <div class="p-4 bg-green-100 rounded">
        <p>Shortened URL: <a class="text-blue-500 hover:text-blue-600" [href]="redirectUrl + shortUrl()"
                target="_blank" (click)="refreshData()">{{ shortUrl() }}</a>
            <button class="ml-2 px-2 py-1 bg-gray-200 text-gray-800 border border-slate-950 rounded hover:bg-gray-300"
                (click)="copyUrl(redirectUrl + shortUrl())">Copy</button>
            @if (copyMessage()) {
            <span class="text-green ml-2">{{ copyMessage() }}</span>
            }
        </p>
    </div>
    }
</div>

<div class="max-w-md mx-auto mt-4 p-2">
    <h2 class="text-2xl font-bold mb-4">All URLs</h2>
    @if (isloading()) {
    <div class="max-w-md mx-auto p-4 shadow-lg rounded-lg">
        <div class="text-center p-4">
            Loading...
        </div>
    </div>
    }
    @else if (error()) {
    <div class="max-w-md mx-auto p-4 shadow-lg rounded-lg">
        <div class="text-center p-4">
            <p class="text-red-500">{{ error() }}</p>
        </div>
    </div>
    }
    @else {
    @if (urls().length > 0 && !isloading() && !error()) {
    <ul>
        @for (url of urls(); track $index) {
        <li class="p-2 border border-gray-300 rounded mb-2">
            <div class="flex justify-between items-center">
                <div>
                    URL:
                    <a class="text-blue-500 hover:text-blue-600" [href]="redirectUrl + url.shortUrl" target="_blank" (click)="refreshData()">{{
                        url.shortUrl }}</a>
                </div>
                <div class="flex justify-between items-center">
                    <button class="px-2 py-1 bg-blue-200 text-blue-800 rounded hover:bg-blue-300"
                        (click)="showDetails(url.shortUrl)">Details</button>
                    <button class="ml-2 px-2 py-1 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
                        (click)="copyListUrl(redirectUrl + url.shortUrl, $index)">{{
                        copyIndex() === $index ? 'Copied' : 'Copy'
                        }}</button>
                    <button class="ml-2 px-2 py-1 bg-red-200 text-red-800 rounded hover:bg-red-300"
                        (click)="prepareDelete(url.shortUrl)">Delete</button>
                </div>
            </div>
        </li>
        }
    </ul>
    }
    @else {
    <div class="max-w-md mx-auto p-4 shadow-lg rounded-lg">
        <div class="text-center p-4">
            No URLs found.
        </div>
    </div>
    }
    }
</div>

@if (showDeleteModal()) {
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
    <div class="bg-white p-4 rounded shadow-lg">
        <h3 class="text-xl font-bold mb-2">Confirm Deletion</h3>
        <p class="mb-4">Are you sure you want to delete this URL?</p>
        <button class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600" (click)="confirmDelete()">Yes,
            Delete</button>
        <button class="px-4 py-2 bg-gray-300 text-gray-800 rounded hover:bg-gray-400 ml-2"
            (click)="showDeleteModal.set(false)">Cancel</button>
    </div>
</div>
}

@if (showDetailsModal()) {
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
    <div class="bg-white p-4 rounded shadow-lg">
        <h3 class="text-xl font-bold mb-2">URL Details</h3>
        @if (isLoading()) {
        <p class="mb-4">Loading...</p>
        }
        @else {
        <p class="mb-4">Short URL: <a class="text-blue-500 hover:text-blue-600"
                [href]="redirectUrl + selectedUrl().shortUrl" target="_blank">{{ selectedUrl().shortUrl }}</a></p>
        <p class="mb-4">Original URL: <a class="text-blue-500 hover:text-blue-600" [href]="selectedUrl().originalUrl"
                target="_blank">{{ selectedUrl().originalUrl }}</a></p>
        <p class="mb-4">Clicks: <span class="text-green-500">{{ selectedUrl().clicks }}</span></p>
        <p class="mb-4">Created At: {{ selectedUrl().createdAt | date: 'medium' }}</p>
        <p class="mb-4">Expires At: {{ selectedUrl().expirationDate | date: 'medium' }}</p>
        <button class="px-4 py-2 bg-gray-300 text-gray-800 rounded hover:bg-gray-400"
            (click)="showDetailsModal.set(false)">Close</button>
        }
    </div>
</div>
}
Enter fullscreen mode Exit fullscreen mode

6. Adding Logic to the Component

Update the component's TypeScript file (src/app/shorten/shorten.component.ts) to handle form submissions and API interactions:

import { Component, inject, OnInit, signal } from '@angular/core';
import { UrlService } from '../services/url.service';
import {
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';
import { Url } from '../models/url.model';
import { environment } from '../../environments/environment';
import { DatePipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';

@Component({
  selector: 'app-shorten',
  standalone: true,
  imports: [DatePipe, ReactiveFormsModule],
  templateUrl: './shorten.component.html',
  styleUrl: './shorten.component.scss',
})
export class ShortenComponent implements OnInit {
    shortUrl = signal('');
    redirectUrl = environment.apiUrl + '/';
    copyMessage = signal('');
    copyListMessage = signal('');
    urls = signal<Url[]>([]);
    showDeleteModal = signal(false);
    showDetailsModal = signal(false);
    urlToDelete = signal('');
    copyIndex = signal(-1);
    selectedUrl = signal<Url>({} as Url);
    isLoading = signal(false);
    isloading = signal(false);
    error = signal('');
    errorMsg = signal('');
    urlForm: FormGroup = new FormGroup({});
    private unsubscribe$: Subject<void> = new Subject<void>();

    urlService = inject(UrlService);

    ngOnInit() {
      this.urlForm = new FormGroup({
        originalUrl: new FormControl('', [
          Validators.required,
          Validators.pattern('^(http|https)://.*$'),
        ]),
      });
      this.getAllUrls();
    }

    shortenUrl() {
      if (this.urlForm.valid) {
        this.urlService.shortenUrl(this.urlForm.value.originalUrl).pipe(takeUntil(this.unsubscribe$)).subscribe({
          next: (response) => {
            this.shortUrl.set(response.shortUrl);
            this.getAllUrls();
          },
          error: (error) => {
            console.error('Error shortening URL: ', error);
            this.errorMsg.set(error?.error?.message || 'An error occurred!');
          },
        });
      }
    }

    refreshData() {
      this.getAllUrls();
    }

    getAllUrls() {
      this.isloading.set(true);
      this.urlService.getAllUrls().pipe(takeUntil(this.unsubscribe$)).subscribe({
        next: (response) => {
          this.urls.set(response);
          this.isloading.set(false);
        },
        error: (error) => {
          console.error('Error getting all URLs: ', error);
          this.isloading.set(false);
          this.error.set(error?.error?.message || 'An error occurred!');
        },
      });
    }

    showDetails(id: string) {
      this.showDetailsModal.set(true);
      this.getDetails(id);
    }

    getDetails(id: string) {
      this.isLoading.set(true);
      this.urlService.getDetails(id).pipe(takeUntil(this.unsubscribe$)).subscribe({
        next: (response) => {
          this.selectedUrl.set(response);
          this.isLoading.set(false);
        },
        error: (error) => {
          console.error('Error getting URL details: ', error);
          this.isLoading.set(false);
          this.error.set(error?.error?.message || 'An error occurred!');
        },
      });
    }

    copyUrl(url: string) {
      navigator.clipboard
        .writeText(url)
        .then(() => {
          console.log('URL copied to clipboard!');
          this.copyMessage.set('Copied!');
          setTimeout(() => {
            this.copyMessage.set('');
          }, 2000);
        })
        .catch((err) => {
          console.error('Failed to copy URL: ', err);
          this.copyMessage.set('Failed to copy URL');
        });
    }

    copyListUrl(url: string, index: number) {
      navigator.clipboard
        .writeText(url)
        .then(() => {
          console.log('URL copied to clipboard!');
          this.copyListMessage.set('Copied!');
          this.copyIndex.set(index);
          setTimeout(() => {
            this.copyListMessage.set('');
            this.copyIndex.set(-1);
          }, 2000);
        })
        .catch((err) => {
          console.error('Failed to copy URL: ', err);
          this.copyListMessage.set('Failed to copy URL');
        });
    }

    prepareDelete(url: string) {
      this.urlToDelete.set(url);
      this.showDeleteModal.set(true);
    }

    confirmDelete() {
      this.showDeleteModal.set(false);
      this.deleteUrl(this.urlToDelete());
    }

    deleteUrl(id: string) {
      this.urlService.deleteUrl(id).pipe(takeUntil(this.unsubscribe$)).subscribe({
        next: (response) => {
          this.getAllUrls();
        },
        error: (error) => {
          console.error('Error deleting URL: ', error);
          this.error.set(error?.error?.message || 'An error occurred!');
        },
      });
    }

    ngOnDestroy() {
      this.unsubscribe$.next();
      this.unsubscribe$.complete();
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Update the app component's HTML file (src/app/app.component.html)

<router-outlet></router-outlet>
Enter fullscreen mode Exit fullscreen mode

8. Update the app config file (src/app/app.config.ts)

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

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

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

9. Update the app routes file (src/app/app.routes.ts)

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

export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./shorten/shorten.component').then((m) => m.ShortenComponent),
  },
];
Enter fullscreen mode Exit fullscreen mode

Conclusion

You have successfully built a URL shortener application using Angular and Tailwind CSS. This project demonstrates how to integrate modern frontend technologies to create a functional and stylish web application. With Angular's powerful features and Tailwind CSS's utility-first approach, you can build responsive and efficient web applications with ease.

Feel free to extend this application by adding features like user authentication, etc. Happy coding!

Exploring the Code

Visit the GitHub repository to explore the code in detail.


. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .