Building an Angular Material QR Code Generator, Deployed on Netlify
Angular and Material Design have become a standard pairing for building modern web interfaces, offering everything from polished components to built-in interaction feedback. Once development wraps up, Netlify provides a streamlined path for deployment with automatic builds and easy traffic management. This walkthrough covers the entire process — creating an Angular 8 project, integrating the official Material Design library, and pushing the finished QR code generator to Netlify. The complete source is available on GitHub.
Prerequisites
Before starting, you'll need four things in place:
The steps below use VSCode on Windows, but the workflow is identical on other editors and operating systems.
Planning the UI
A little upfront design work avoids confusion later. Three pages make up this application, each with its own responsibility:
- Homepage — the starting point and central hub.
- Create QR — where users generate new codes.
- History — a list of previously saved codes.
Reviewing the mockups reveals the top navigation bar persists across all three views. That makes it an ideal candidate for a reusable component, saving repetition and keeping the layout consistent.
Scaffolding the Project
Open a terminal in VSCode. Navigate to the directory where you want the project to live using cd, then create the project with:
ng new qr
Angular CLI will prompt you through a few configuration choices. For this tutorial, answer Yes when asked about routing and select CSS for styling. A fully functional Angular skeleton is generated in a new qr folder.
To verify the scaffold works, try running ng serve. If you're still in the parent directory, this fails — angular-cli only creates the project inside a new folder, not in the current one. Change into it first:
cd qr
Now run the server again with ng serve, then point your browser to https://localhost:4200. The default port is 4200; use ng serve --port 3000 or any other number to switch ports. Once the default page loads, open the project folder in VSCode using File → Open Folder so you can work with the files directly.
Adding Angular Material
Bring the Material Design library in with the single CLI command:
ng add @angular/material
The CLI will again ask for preferences. Choose the default Indigo/Pink theme, and accept the prompts to add HammerJS and browser animations. Beyond copying in the library, this command configures the entire project for Material Design components:
- Registers dependency entries in package.json.
- Adds the Roboto font to index.html.
- Adds the Material Design icon font to index.html.
- Applies global CSS resets such as removing body margins, setting
height: 100%onhtmlandbody, and making Roboto the default typeface.
Rebuilding and serving the app now won't yet show visual changes — the Material components haven't been added to any templates. That groundwork pays off in the next step, when the actual UI elements take shape.
Building the Home Page and Core Services
With the project skeleton in place, we can begin constructing the home page and its supporting pieces. The home page has a simple layout: a top navigation bar with the primary theme color, a centered avatar image, a button showing the user's saved QR code count, and a floating action button in the bottom-right corner for creating new codes.
Creating a Reusable Header Component
Rather than duplicating the navigation bar across pages, we create it as a standalone component. In the terminal, run:
ng g c header
This generates four files: header.component.css, header.component.html, header.component.spec.ts, and header.component.ts. The header's HTML uses Angular Material's mat-button and mat-icon elements, which requires importing their respective modules into app.module.ts:
mat-icon and mat-button (Large preview)mat-button is one of several button types available — mat-raised-button, mat-flat-button, and mat-fab offer different visual styles, and you can swap between them easily. The mat-icon element pulls from Google's material icon library, which was already linked when Angular Material was added to the project.
<mat-icon style="color: white;">
<i class="material-icons md-32">arrow_back</i>
</mat-icon>
To increase the icon's default size, nest an i tag with a class like md-32 (32px) or md-48 (48px). The icon's name is specified in the tag's text content, and you can find a full list of icon names on the Material Design resources site.
Accessibility Considerations
ARIA (Accessible Rich Internet Applications) helps make web content usable by assistive technologies. Standard HTML elements like nav already have native semantics understood by screen readers, so they don't need ARIA roles. For elements without native meaning — a div styled as a progress bar, for example — ARIA lets you add a role (what the element is), properties (its characteristics), and states (its current status).
<div id="percent-loaded" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100"> </div>
One common attribute is aria-hidden=true/false, which hides elements from screen readers when set to true. Since this application primarily uses semantic HTML elements, the only ARIA attributes needed are visibility states. See MDN's WAI-ARIA guide for deeper coverage.
The header component's template includes logic to conditionally show a back button based on the current route. The home button contains a logo image, which must be placed in the project's /assets folder. Add the header structure and styling to its respective files:
<nav class="navbar" [class.mat-elevation-z8]=true>
<div>
<button *ngIf="showBackButton" aria-hidden=false mat-icon-button routerLink="/">
<mat-icon style="color: white;">
<i class="material-icons md-32">arrow_back</i>
</mat-icon>
</button>
<span style="padding-left: 8px; color: white;">{{currentTitle}}</span>
</div>
<button *ngIf="!showBackButton" aria-hidden=false mat-button class="button">
<img src="../../assets/qr-icon-white.png" style="width: 40px;">
<span style="padding-left: 8px;">QR Generator</span>
</button>
<button *ngIf="showHistoryNav" aria-hidden=false mat-button class="button" routerLink="/history">
<span style="padding-left: 8px;">History</span>
</button>
</nav>
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2;
background: #3f51b5;
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 12px 16px;
}
.button {
color: white;
margin: 0px 10px;
}
The header stays reusable by accepting parameters from other components via decorators in header.component.ts:
// Add these three lines above the constructor entry.
@Input() showBackButton: boolean;
@Input() currentTitle: string;
@Input() showHistoryNav: boolean;
constructor() { }
Setting Up the Home Component and Routing
With the header ready, create the home page shell with ng g c home. Angular's router maps URLs to components, and the application has three navigable pages. Add the empty string path value to route the base URL to the home component in app-routing.module.ts — Angular paths never begin with a forward slash.
The home component's template composes the header, a profile area, and the action button:
<app-header [showBackButton]="false" [currentTitle]=""></app-header>
<app-profile></app-profile>
<!-- FAB Fixed -->
<button mat-fab class="fab-bottom-right" routerLink="/create">
<mat-icon>
<i class="material-icons md-48">add</i>
</mat-icon>
</button>
The <app-header></app-header> tag demonstrates how the reusable component is embedded and receives its required inputs. The floating action button uses routerLink="/create" to navigate to the creation page. Apply positioning CSS so the button sits at the bottom right:
.fab-bottom-right {
position: fixed;
left: auto;
bottom: 5%;
right: 10%;
}
Adding the Profile Component
Generate the profile component with ng g c profile. Its template includes the material badge element showing the user's QR count:
<div class="center profile-child">
<img class="avatar" src="../../assets/avatar.png">
<div class="profile-actions">
<button mat-raised-button matBadge="{{historyCount}}" matBadgeOverlap="true" matBadgeSize="medium" matBadgeColor="accent"
color="primary" routerLink="/history">
<span>History</span>
</button>
</div>
</div>
The matBadge requires importing MatBadgeModule into app.module.ts. Angular Material badges offer configurable attributes like matBadgePosition, matBadgeSize, and matBadgeColor. An avatar image is also needed in the assets folder. After adding the component's styling, wire up the logic:
export class ProfileComponent implements OnInit {
historyCount = 0;
constructor(private storageUtilService: StorageutilService) { }
ngOnInit() {
this.updateHistoryCount();
}
updateHistoryCount() {
this.historyCount = this.storageUtilService.getHistoryCount();
}
}
This references a StorageutilService that doesn't exist yet — we'll create it next.
Working with Local Storage
HTML5's web storage gives far more capacity than cookies (at least 5MB compared to 4KB). Two storage types are available: local (permanentacross sessions) and session (temporary for a browsingsession). Here we use local storage to persist QR codes.
Data is stored as key/value pairs. The text of the QR becomes the key, and the base64-encoded QR image is the value. Create an entityfolder with a qr-object.ts class:
export class QR {
text: string;
imageBase64: string;
constructor(text: string, imageBase64: string) {
this.imageBase64 = imageBase64;
this.text = text;
}
}
Each saved QR creates an instance of this class. Create a services folder and generate the service:
cd services
ng g s storageutil
In storageutil.service.ts, add the service logic:
private historyCount: number;
constructor() { }
saveHistory(key : string, item :string) {
localStorage.setItem(key, item)
this.historyCount = this.historyCount + 1;
}
readHistory(key : string) : string {
return localStorage.getItem(key)
}
readAllHistory() : Array<QR> {
const qrList = new Array<QR>();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
if (key && value) {
const qr = new QR(key, value);
qrList.push(qr);
}
}
this.historyCount = qrList.length;
return qrList;
}
getHistoryCount(): number {
if (this.historyCount) {
return this.historyCount;
}
this.readAllHistory();
return this.historyCount;
}
deleteHistory(key : string) {
localStorage.removeItem(key)
this.historyCount = this.historyCount - 1;
}
The localStorage keyword is available directly — no extra imports needed beyond the QR object class. After importing the service into profile.component.ts, the page is complete.
To add elevation to any Material component, attach [class.mat-elevation-z8]=true. The number after z controls the depth, e.g. z16 for a higher elevation.
Plugging in the Create QR Page
The homepage’s “create” button is wired to route to /create, but the route itself doesn’t exist yet. Generate a new component with ng g c create-qr, then register it in app-routing.module.ts:
{ path: 'create', component: CreateQrComponent },
The component’s template makes heavy use of Material components. The body uses a mat-card as a container with elevation set via [class.mat-elevation-z12]=true. Material’s mat-card can play a role beyond simple data display — here it works just like any other layout div.
<app-header [showBackButton]="showBackButton" [currentTitle]="title" [showHistoryNav]="showHistoryNav"></app-header>
<mat-card class="qrCard" [class.mat-elevation-z12]=true>
<div class="qrContent">
<!--Close button section-->
<div class="closeBtn">
<button mat-icon-button color="accent" routerLink="/" matTooltip="Close">
<mat-icon>
<i class="material-icons md-48">close</i>
</mat-icon>
</button>
</div>
<!--QR code image section-->
<div class="qrImgDiv">
<img *ngIf="!showProgressSpinner" style="padding: 5px 5px;" src={{qrCodeImage}} width="200px" height="200px">
<mat-spinner *ngIf="showProgressSpinner"></mat-spinner>
<div class="actionButtons" *ngIf="!showProgressSpinner">
<button mat-icon-button color="accent" matTooltip="Share this QR" style="margin: 0 5px;">
<mat-icon>
<i class="material-icons md-48">share</i>
</mat-icon>
</button>
<button mat-icon-button color="accent" (click)="saveQR()" matTooltip="Save this QR" style="margin: 0 5px;">
<mat-icon>
<i class="material-icons md-48">save</i>
</mat-icon>
</button>
</div>
</div>
<!--Textarea to write any text or link-->
<div class="qrTextAreaDiv">
<mat-form-field style="width: 80%;">
<textarea matInput [(ngModel)]="qrText" cdkTextareaAutosize cdkAutosizeMinRows="4" cdkAutosizeMaxRows="4"
placeholder="Enter a website link or any text..."></textarea>
</mat-form-field>
</div>
<!--Create Button-->
<div class="createBtnDiv">
<button class="createBtn" mat-raised-button color="accent" matTooltip="Create new QR code" matTooltipPosition="above"
(click)="createQrCode()">Create</button>
</div>
</div>
</mat-card>
Several other Material features appear in this view:
matTooltipshows explanatory text on hover or long-press. Its placement is controlled withmatTooltipPosition.mat-spinnerrenders a progress indicator. TheshowProgressSpinnerboolean toggles it on and off during the network request. The indeterminate mode (via[mode]='indeterminate') is used since the request duration is unknown; a determinate spinner would be appropriate when progress can be tracked.matInputis an attribute directive that adapts a nativeinputortextareato work insidemat-form-field. For auto-resizing textareas, paircdkTextareaAutosizewithcdkAutosizeMinRowsandcdkAutosizeMaxRows.
Every Material module used by the template must be declared in app.module.ts:
A placeholder image is referenced from /assets; save that download into the folder. CSS for the component goes in the component stylesheet:
.qrCard {
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20%;
height: 65%;
padding: 50px 20px;
}
.qrContent {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.qrTextAreaDiv {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
padding: 0px 0px;
position: absolute;
bottom: 10%;
}
.createBtn {
left: 50%;
transform: translate(-50%, 0px);
width: 80%;
}
.createBtnDiv {
position: absolute;
bottom: 5%;
width: 100%;
}
.closeBtn {
display: flex;
flex-direction: row-reverse;
align-items: flex-end;
width: 100%;
margin-bottom: 20px;
}
.closeBtnFont {
font-size: 32px;
color: rgba(0,0,0,0.75);
}
.qrImgDiv {
top: 20%;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.actionButtons {
display: flex;
flex-direction: row;
padding-top: 20px;
}
To connect the template to the data flow, update create-qr.component.ts with the logic that posts the input text and holds the returned image:
export class CreateQrComponent implements OnInit {
qrCodeImage = '../../../assets/download.png';
showProgressSpinner = false;
qrText: string;
currentQR;
showBackButton = true;
title = 'Generate New QR Code';
showHistoryNav = true;
constructor(private snackBar: MatSnackBar,
private restutil: RestutilService,
private storageService: StorageutilService) { }
ngOnInit() {
}
createQrCode() {
//Check if any value is given for the qr code text
if (!!this.qrText) {
//Make the http call to load qr code
this.loadQRCodeImage(this.qrText);
} else {
//Show snackbar
this.showSnackbar('Enter some text first')
}
}
public loadQRCodeImage(text: string) {
// Show progress spinner as the request is being made
this.showProgressSpinner = true;
// Trigger the API call
this.restutil.getQRCode(text).subscribe(image =>{
// Received the result - as an image blob - require parsing
this.createImageBlob(image);
}, error => {
console.log('Cannot fetch QR code from the url', error)
// Hide the spinner - show a proper error message
this.showProgressSpinner = false;
});
}
private createImageBlob(image: Blob) {
// Create a file reader to read the image blob
const reader = new FileReader();
// Add event listener for "load" - invoked once the blob reading is complete
reader.addEventListener('load', () => {
this.qrCodeImage = reader.result.toString();
//Hide the progress spinner
this.showProgressSpinner = false;
this.currentQR = reader.result.toString();
}, false);
// Read image blob if it is not null or undefined
if (image) {
reader.readAsDataURL(image);
}
}
saveQR() {
if (!!this.qrText) {
this.storageService.saveHistory(this.qrText, this.currentQR);
this.showSnackbar('QR saved')
} else {
//Show snackbar
this.showSnackbar('Enter some text first')
}
}
showSnackbar(msg: string) {
//Show snackbar
this.snackBar.open(msg, '', {
duration: 2000,
});
}
}
MatSnackBar is a service for transient notifications that slide up from the bottom of the viewport. After importing the module (also in app.module.ts), a snackbar is shown via the showSnackbar method in the component. Reskinning the snackbar isn’t a one-liner; it requires a globally-declared CSS class and then referencing that class through the panelClass property.
::ng-deep snack-bar-container.snackbarColor {
background-color: rgba(63, 81, 181, 1);
}
::ng-deep .snackbarColor .mat-simple-snackbar {
color: white;
}
this.snackBar.open(msg, '', {
duration: 2000,
panelClass: ['snackbarColor']
});
Fetching QR Images and Caching
A RestutilService is still missing, and the component will error without it. Generate the service with ng g s restutil. This service talks to the third-party API and is configured for an image response rather than JSON:
private edgeSize = '300';
private BASE_URL = 'https://api.qrserver.com/v1/create-qr-code/?data={data}!&size={edge}x{edge}';
constructor(private httpClient: HttpClient) { }
public getQRCode(text: string): Observable {
// Create the url with the provided data and other options
let url = this.BASE_URL;
url = url.replace("{data}", text).replace(/{edge}/g, this.edgeSize);
// Make the http api call to the url
return this.httpClient.get(url, {
responseType: 'blob'
});
}
The service uses Angular’s HttpClient, for which HttpClientModule must also be registered in app.module.ts.
Qubit of inefficiency — each “Create” click fires a fresh network call, even if the same text was just encoded. To avoid the duplicate work, build an interceptor that serves a cached response on repeat requests. Create a folder for interceptors and add cache-interceptor.ts:
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpResponse, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { tap } from 'rxjs/operators';
import { of, Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class RequestCachingService implements HttpInterceptor {
private cacheMap = new Map<string, HttpResponse<any>>();
constructor() { }
intercept(req: HttpRequest, next: HttpHandler): Observable<HttpEvent<any>> {
const cachedResponse = this.cacheMap.get(req.urlWithParams);
if (cachedResponse) {
return of(cachedResponse);
}
return next.handle(req).pipe(tap(event => {
if (event instanceof HttpResponse) {
this.cacheMap.set(req.urlWithParams, event);
}
}))
}
}
The interceptor keeps a Map keyed by request URL. If the current URL matches an entry, the cached response is returned; otherwise the request passes through and is stored after completion. Register the interceptor alongside the other providers:
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { CacheInterceptor } from './interceptor/cache-interceptor';
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor, multi: true }
],
History Page
QR codes that have been saved appear on the History page. Generate that component with ng g c history. The layout relies on mat-grid-list and mat-grid-tile from the Material grid system. The tile sizing takes both a column count and a fixed rowHeight. The empty state uses another asset — pull the no-see placeholder into /assets.
<app-header [showBackButton]="showBackButton" [currentTitle]="title" [showHistoryNav]="showHistoryNav"></app-header>
<div class="main-content">
<mat-grid-list cols="4" rowHeight="500px" *ngIf="historyList.length > 0">
<mat-grid-tile *ngFor="let qr of historyList">
<mat-card>
<img mat-card-image style="margin-top: 5px;" src="{{qr.imageBase64}}">
<mat-card-content>
<div class="truncate">
{{qr.text}}
</div>
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="share(qr.text)">SHARE</button>
<button mat-button color="accent" (click)="delete(qr.text)">DELETE</button>
</mat-card-actions>
</mat-card>
</mat-grid-tile>
</mat-grid-list>
<div class="center-img" *ngIf="historyList.length == 0">
<img src="../../assets/no-see.png" width="256" height="256">
<span style="margin-top: 20px;">Nothing to see here</span>
</div>
</div>
.main-content {
padding: 5% 10%;
}
.truncate {
width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.center-img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
}
Component logic simply reads saved QR entries from local storage and renders a delete affordance on each card. Deleting a card also removes its localStorage entry.
showBackButton = true;
title = 'History';
showHistoryNav = false;
historyList;
constructor(private storageService: StorageutilService,
private snackbar: MatSnackBar ) { }
ngOnInit() {
this.populateHistory();
}
private populateHistory() {
this.historyList = this.storageService.readAllHistory();
}
delete(text: string) {
this.storageService.deleteHistory(text);
this.populateHistory();
}
share(text: string) {
this.snackbar.open(text, '', {duration: 2000,})
}
Route mappings come next in app-routing.module.ts:
{ path: 'history', component: HistoryComponent },
This brings the routes array to:
const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'create', component: CreateQrComponent },
{ path: 'history', component: HistoryComponent },
];
Run ng serve and test the flow at localhost:4200.
Push to GitHub
Prior to deployment, put the project under version control:
- Open GitHub and create a new repository.
- In VS Code’s terminal, run the initial commit commands shown in the quick start guide to move all project files up.
After pushing, refresh the repository page to confirm everything is present. Going forward, all commits and syncs will be reflected in that repo.
Deploying To Netlify
Running the application locally is only the first step. To make it publicly accessible, the project needs to be deployed to a cloud platform and attached to a domain. Netlify provides continuous deployment, GitHub integration, and a range of other tools that make this process straightforward. The steps to get the qr application live are as follows.
- Sign up on Netlify and log in to the dashboard.
- Click New site from Git.
- Select GitHub as the Git provider in the next screen.
- Authorize Netlify to access your GitHub repositories.
- Search for and choose the newly created
qrrepository. - Select the branch to deploy. Typically this is
master, though a separatereleasebranch with stable features can also be used.
Because this is an Angular web application, the build command is ng build --prod. The publish directory is dist/qr, as already declared in the angular.json configuration file.
Click Deploy site. Netlify will run the build command and output the compiled files to dist/qr. Having supplied the correct path, Netlify automatically picks up the files needed to serve the web application. A randomly generated domain is assigned to the site by default, giving you a live URL that can be accessed from anywhere.
Setting A Custom Domain
The automatically generated sub-domain can be replaced with a custom site name. In the Netlify dashboard, click Domain settings. In the Custom Domains section, open the three-dot menu and choose Edit site name.
A popup appears where a new site name can be entered. The chosen name must be unique across Netlify's domain system. After entering an available name, click Save, and the application link updates accordingly.
Split Testing With Netlify
Netlify's split testing feature distributes traffic between different application deployments. This allows developers to introduce new features on a separate branch, direct a percentage of users to that deployment, analyze the results, and merge the branch once confident. Configuring split testing requires a GitHub repository with at least two branches. In the qr repository on GitHub, create a new branch named a.
The repository now contains both the master and a branches. Netlify must be configured for branch deployments. From the dashboard, open Settings, then Build & Deploy in the left sidebar, and finally Continuous Deployment. In the Deploy contexts section on the right, click Edit settings.
In the Branch deploys sub-section, choose the option "Let me add individual branches," then enter the branch names and save. This setup enables deployments for specific GitHub branches and also supports previews for every pull request against master before merging, giving developers a live environment to test changes without affecting the main deployment.
Next, click the Split Testing tab at the top of the page to access the configuration options.
Select a branch other than the production branch — in this case a. Adjust the traffic percentage allotted to each branch as needed. Netlify then routes incoming users to either the a branch deployment or the master deployment. Click Start test to enable traffic splitting.
Tip: If Netlify does not detect that the connected GitHub repository has more than one branch, an error may appear.
This can be resolved by reconnecting to the repository from the Build & Deploy settings. These examples cover some of the most useful Netlify capabilities, but the platform offers much more. The Angular Material application is now created, built, and successfully deployed.
Final Thoughts
Angular remains a widely adopted framework for building web applications, and its official Material Design library simplifies the process of creating interfaces that follow design specifications and offer natural user interactions. Pairing an Angular application with a robust deployment platform like Netlify ensures the project can reach users efficiently. With continuous evolution, strong support, and an extensive feature set, Netlify is well suited for bringing both web applications and static sites to a global audience.



