JAMstack Angular apps with Scully and Netlify

angular

727 views

JAMstack Angular apps with Scully and Netlify blog meme

There's no denying that the JAMstack has taken the traditional web development world by storm and turned our previous tried and true development models on their heads. The JAMstack is not new, per se, but has become an increasingly popular approach to how we write and deploy web applications in the modern developer landscape. With frameworks like Next.js, Nuxt, Gatsby, Hugo, and many others, we as developers have been given the tools to develop entirely serverless applications that run as static web apps in the browser.

Embrace "serverless" here with big air quotes - you're still running on someone's server, and your choice of hosting provider will more than likely dictate just how "serverless" you can be

As Angular developers, we love our framework and everything it provides. Unfortunately, turning your Angular application into a static web app is not as simple as one may like. While Angular Universal does give us an officially supported devtool to turn our Angular apps into statically rendered pages, I've found it a bit on the configure-y side and not as simple to use as I'd like compared to other frameworks like Next.js.

What I'd wished I found earlier before going down my Angular JAMstack journey is Scully, the Angular static site generator I never knew I needed and JAMstack tool that, quite frankly, is just plain fun to use.

JAMstack?

Yep, the JAMstack: JavaScript, APIs, and Markup. There's a thriving community of developers embracing the JAMstack, so defer to them for the nitty gritty details. The JAMstack follows a general artchitecture:

  • Markup is pre-rendered at build time and served as static HTML and CSS
  • JavaScript still provides all its native interactivity with the browser, while not bogging down the initial rendering of our apps with retrieving JS bundles
  • APIs that provide data, processing, and whatever else we need from a backend server

In essence, JAMstack principles align to provide fast, reliable, scalable, and performant web applications for the modern browser.

What this means in practice when you write framework code for say, Next.js, we write our normal React code we'd normally write, with pages being rending in a static context - every element on the page is inspect-able in the browser, which means much more easily indexable by search engines.

What is Scully?

Scully is a tool/framework developed by community members for Angular that turns your Angular application into a statically rendered website, with fully indexable HTML allowing for all the SEO optimization your heart desires while still delivering on the development workflow we Angular developers love about the framework. While I claim to be no Scully expert, I've built a few side project client sites using Scully, while having re-written this exact blog you're reading from Next to Scully.

Scully works on the premise of analyzing your application's routing and compiling a separate build artifact containing all scanned routes as individual index.html pages. While there's a lot more that goes on under the hood and a bit out of my knowledge realm, Scully effectively allows us to turn our Angular SPAs into fully static sites that load quickly to the browser, vastly improve SEO, allows improved search engine indexing, and the list goes on.

The Scully team has great documentation on using the tool, alongside a plethora of build plugins, both from Scully and the community, allowing you to tap into the Scully lifecycle at different points in the build process to customize the generated static files to your need.

Hosting with Netlify

With Scully generating an entirely static version of our application, it becomes trivial to host such files on any static site hosting service. While there's a lot of great services out there, I prefer Netlify as it allows us to tap into Netlify Functions, serving as the A in JAMstack and giving us the ability to build serverless functions that our static Angular applications can call into, performing any work you'd normally do on the server. Using Netlify, not only are we able to host our Angular apps as fully static sites, but we can also build out a mesh of serverless functions that act as a backend-lite for any of our API-like needs! Who said you can't have your cake and eat it too?

Getting started with Scully

This all sounds great, but let's jump into some code to see how we can make our Angular JAMstack dream a reality. Let's kick things off by creating a new Angular application with routing:

ng new scully-netlify-angular-example --routing

Scully utilizes the Angular router to crawl all of our application routes, allowing it to create individual servable HTML files by reading our router configuration. With our code scaffolding in place, let's add some simple components and a few pages:

cd scully-netlify-angular-example
ng g c components/home --skip-tests
ng g c components/ping --skip-tests
ng g c components/data --skip-tests

We're skipping testing for the purposes of this post. With our components in place, let's add them to our app-routing.module.ts:

app-routing.module.ts

import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { DataComponent, HomeComponent, PingComponent } from '@components'

const routes: Routes = [
    {
        path: '',
        component: HomeComponent,
    },
    {
        path: 'ping',
        component: PingComponent,
    },
    {
        path: 'data',
        component: DataComponent,
    },
]

@NgModule({
    imports: [RouterModule.forRoot(routes)],
    exports: [RouterModule],
})
export class AppRoutingModule {}

With routes in place, let's implement a few services that will help facilitate talking to our Netlify functions (more on those in a bit). Let's implement two services: one for the PingComponent that will consume a PingService to ping the server, and one for DataComponent to offload the responsibility of sending and receiving data from the server.

ng g s services/ping --skip-tests
ng g s services/data --skip-tests

Since we'll be utilizing a bit with Observables, let's implement a service to clean up our streams once a component is destroyed:

ng g s services/unsubscribe --skip-tess

While we're at it, let's make a quick service to help us with our SEO to set page titles, update <meta>s, etc.

ng g s services/document --skip-tests

With our services in place and having no further dependencies (outside the standard Angular library), let's implement their code. First, our UnsubscribeService:

unsubscribe.service.ts

import { Injectable, OnDestroy } from '@angular/core'
import { Subject } from 'rxjs'

@Injectable()
export class UnsubscribeService extends Subject<void> implements OnDestroy {
    ngOnDestroy(): void {
        this.next()
        this.complete()
    }
}

As we can see, our UnsubscribeService is no more than a glorified injectable Subject that emits nothing and simply closes itself on service destruction. This will be helpful to close connections to Observables we're .subscribe()ing to within our components. Next, let's put our PingService code in place:

ping.service.ts

import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { ServerResponse } from 'types'
import { catchError, delay, map } from 'rxjs/operators'
import { of } from 'rxjs'
import { environment } from '@environment'

@Injectable({
    providedIn: 'root',
})
export class PingService {
    constructor(private http: HttpClient) {}

    pingServer() {
        const baseUrl = `${environment.functionsBaseDomain}${environment.functionsBasePath}`

        return this.http.get<ServerResponse>(`${baseUrl}/ping`).pipe(
            map((response) => response.message),
            delay(1000),
            catchError((error) => {
                console.log(error)
                return of('Whoops... an error occurred :(')
            })
        )
    }
}

Next, our DataService:

data.service.ts

import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { environment } from '@environment'
import { EMPTY } from 'rxjs'
import { catchError, delay, map } from 'rxjs/operators'
import { ServerResponse } from 'types'

@Injectable({
    providedIn: 'root',
})
export class DataService {
    constructor(private http: HttpClient) {}

    sendDataToServer(name: string) {
        const baseUrl = `${environment.functionsBaseDomain}${environment.functionsBasePath}`

        return this.http
            .post<ServerResponse>(`${baseUrl}/data`, {
                name,
            })
            .pipe(
                delay(1000),
                map((response) => response.message),
                catchError((error) => {
                    console.log(error)
                    return EMPTY
                })
            )
    }
}

and finally,

document.service.ts

import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { Meta, MetaDefinition } from '@angular/platform-browser'

@Injectable({
    providedIn: 'root',
})
export class DocumentService {
    private readonly rootTitle = 'Scully, Angular, and Netlify'

    constructor(
        @Inject(DOCUMENT) private readonly document: Document,
        private meta: Meta
    ) {}

    resetTitle(): void {
        this.document.title = this.rootTitle
        this.meta.updateTag({
            name: 'title',
            content: this.rootTitle,
        })
    }

    updateTitle(pageTitle: string): void {
        this.document.title = `${this.rootTitle} - ${pageTitle}`
        this.meta.updateTag({
            name: 'title',
            content: pageTitle,
        })
    }

    setMetaTags(tags: MetaDefinition[]) {
        tags.map((t) => {
            const existingTag = this.meta.getTag(t.id ?? '')
            if (!existingTag) {
                this.meta.addTag(t)
            }
        })
    }
}

These services will be responsible for sending HTTP requests to our serverless functions. Let's add some code to our ping component:

ping.component.ts

import { Component, OnInit } from '@angular/core'
import { DocumentService, PingService, UnsubscribeService } from '@services'
import { takeUntil } from 'rxjs/operators'

@Component({
    selector: 'app-ping',
    templateUrl: './ping.component.html',
})
export class PingComponent implements OnInit {
    message = 'pinging the server...'

    constructor(
        private documentService: DocumentService,
        private pingService: PingService,
        private unsubscribe$: UnsubscribeService
    ) {}

    ngOnInit(): void {
        this.documentService.updateTitle('ping')
        this.documentService.setMetaTags([
            {
                name: 'author',
                content: 'Joey McKenzie',
            },
        ])

        this.pingService
            .pingServer()
            .pipe(takeUntil(this.unsubscribe$))
            .subscribe(
                (message) => (this.message = `response from server: ${message}`)
            )
    }
}

and the markup:

ping.component.html

<p>{{ message }}</p>

PingComponent uses PingService to ping the server for a response, then display said response in our browser. Next, data.component.ts:

data.component.ts

import { Component, OnInit } from '@angular/core'
import { DocumentService, DataService, UnsubscribeService } from '@services'
import { finalize, takeUntil } from 'rxjs/operators'

@Component({
    selector: 'app-data',
    templateUrl: './data.component.html',
})
export class DataComponent implements OnInit {
    loading = false
    response?: string

    constructor(
        private documentService: DocumentService,
        private dataService: DataService,
        private unsubscribe$: UnsubscribeService
    ) {}

    ngOnInit(): void {
        this.documentService.updateTitle('data')
    }

    loadData() {
        this.loading = true

        this.dataService
            .sendDataToServer('Joey')
            .pipe(
                takeUntil(this.unsubscribe$),
                finalize(() => (this.loading = false))
            )
            .subscribe((response) => (this.response = response))
    }
}

I'm using alias imports in @environment, @services, and @components to make my import paths look tolerable. We do so by adding a paths node to compilerOptions:

tsconfig.json

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
    // ...other options,
    "compilerOptions"
:
    {
        // ...some more options,
        "paths"
    :
        {
            "@components"
        :
            ["src/app/components/index.ts"],
                "@services"
        :
            ["src/app/services/index.ts"],
                "@environment"
        :
            ["src/environments/environment.ts"]
        }
    }
,
}

Where each index.ts file exports out either services or components in their relative directories and exports our application environment configuration.

With that out of the way, let's wire up our components in the HomeComponent and add some links to navigate between pages:

home.component.ts

import { Component, OnInit } from '@angular/core'
import { DocumentService } from '../../services/document.service'

@Component({
    selector: 'app-home',
    templateUrl: './home.component.html',
})
export class HomeComponent implements OnInit {
    constructor(private documentService: DocumentService) {}

    ngOnInit(): void {
        this.documentService.resetTitle()
    }
}

and with markup,

home.component.html

<div class="flex flex-row space-x-4">
    <a routerLink="/ping">
        <button
            type="button"
            class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
        >
            Ping
        </button>
    </a>
    <a routerLink="/data">
        <button
            type="button"
            class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
        >
            Data
        </button>
    </a>
</div>

While we're at it, let's go ahead and update app.component.html with some routing:

app.component.html

<router-outlet></router-outlet>
<div class="flex flex-row justify-center pt-12">
    <a routerLink="/">
        <button
            type="button"
            class="inline-flex items-center rounded-md border border-transparent bg-red-500 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700"
        >
            Home
        </button>
    </a>
</div>

I'm using Tailwind out of habit, and not required for the purposes of this example by any means. Let's kick off our dev server and we should see after navigating to localhost:4200 a page in our browser with three buttons: ping, data, and home.

Home page

With devtools open, if we click the ping button that routes us to the page with our PingComponent, we get an error due to a failed HTTP request to http://localhost:9999/.netlify/functions/ping, and the same happens when we click the data button to navigate to the page with our DataComponent and click the Load data button. Our services are correctly calling out to our serverless functions, but there's one problem: we don't have any functions!

Going serverless with Netlify Functions

If you're familiar with static site hosting services, Netlify is another one of those with much, much more to offer than simple site hosting. One of my favorite features of Netlify is Netlify Functions - Netlify's version of serverless functions that utilize AWS lambda functions behind the scenes to provide an easy way to integrate backend functionality with our statically served web apps. I've found serverless functions helpful in quite a few different ways:

  • Sending out emails using email API services
  • Integrating with third-party APIs to do some processing for frontend requests
  • Serving simple data to be consumed from the frontend
  • Heck, even persisting to database based on requests from our frontend applications

Our serverless functions will serve, more or less, as an API-lite for our static served Angular Scully application that will be hosted in Netlify, allowing us to connect our outgoing HTTP calls from the frontend to the backend functions listening for requests. Functions work on the premise of running code at a specified endpoint that receive HTTP events (methods, headers, request body, etc.) and the context of the HTTP request (metadata about the function itself). There's a lot we can customize around the way our functions are detected, served, and ran, but we'll utilize all the defaults for now.

To get started writing functions, let's go ahead and install the Netlify Functions package and the Netlify CLI to help us write and serve our functions:

npm install @netlify/functions
npm install --save-dev netlify-cli # -g if you'd like to install globally

Using the CLI, let's add an npm script in our package.json we can run to start our function server that will detect any code changes we apply while writing our functions, recompile them, and serve them up for our frontend to call into:

{
    // ...other stuff
    "scripts": {
        // ...other scripts
        "functions:serve": "netlify functions:serve"
    }
}

If we run:

npm run functions:serve

we should see a message in our console along the lines of ready to serve, but no functions available. Since we've haven' t provided any customized configuration for our functions, the CLI assumes your functions exist under a /netlify/functions folder at the root of your project. Let's implement a /ping GET endpoint using TypeScript that will tap into the functionality provided by the @netlify/functions package to start listening for requests:

netlify/functions/ping.ts

import { Handler } from '@netlify/functions'
import {
    getErrorResponse,
    getHeaders,
    HttpStatus,
    isWhitelistedDomain,
} from '../lib'
import { ServerResponse } from 'types'

const handler: Handler = async (event) => {
    const origin = event.headers.origin ?? ''

    if (!isWhitelistedDomain(origin)) {
        return getErrorResponse(HttpStatus.Forbidden)
    }

    if (event.httpMethod.toLowerCase() !== 'get') {
        return getErrorResponse(HttpStatus.MethodNotAllowed)
    }

    const response: ServerResponse = {
        success: true,
        message: 'pong',
    }

    return {
        statusCode: 200,
        body: JSON.stringify(response),
        headers: getHeaders(origin),
    }
}

export { handler }

I've added some common code that I'll be sharing between functions and have moved that to a lib folder with a single index.ts file:

netlify/lib/index.ts

import { Response } from '@netlify/functions/dist/function/response'
import { ServerResponse } from 'types'

enum HttpStatus {
    Forbidden = '403',
    MethodNotAllowed = '405',
    InternalServerError = '500',
}

const WHITELIST_DOMAINS = [
    '', // Postman doesn't send request from an origin, allow it through (DON'T DO THIS IN PRODUCTION)
    'http://localhost:4200', // Whitelist our Angular dev serve for local development
    'http://localhost:1668', // Whitelist our Scully server for local Scully development
    'https://asdfasdf.netlify.app', // Whitelist our production server deployed to Netlify
]

function getHeaders(origin: string): { [key: string]: string } {
    return {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Headers': '*',
    }
}

function isNullOrUndefined(
    objectToValidate?: unknown | null | undefined
): boolean {
    return objectToValidate === null || objectToValidate === undefined
}

function isWhitelistedDomain(origin: string): boolean {
    const existingDomain = WHITELIST_DOMAINS.find((d) => d === origin)
    return !isNullOrUndefined(existingDomain)
}

function getErrorResponse(statusCode: HttpStatus): Response {
    let message: string

    switch (statusCode) {
        case HttpStatus.Forbidden:
            message = 'That method is not allowed for this endpoint.'
            break
        case HttpStatus.MethodNotAllowed:
            message = 'Domain is forbidden from accessing.'
            break
        default:
            message = 'An invalid request was made, please try again.'
    }

    return {
        statusCode: +statusCode,
        body: JSON.stringify({
            success: false,
            message,
        } as ServerResponse),
        headers: {
            'Content-Type': 'application/json',
        },
    }
}

export { getErrorResponse, getHeaders, isWhitelistedDomain, HttpStatus }

Nothing too complicated, just a few simple helpers to facilitate validating allowed origins for CORS, some common response generators, and a method to grab some default headers for each response. I've also reused the ServerResponse type under the types.ts file at the root of our project:

export type ServerResponse = {
    message: string
    success: boolean
}

Since our project is effectively just a big TypeScript solution (Angular frontend, TS-based serverless functions), we can import any .ts files into our serverless functions, given it runs in the correct context. I wouldn't want to reference any @angular/* imports, for obvious reasons. With our ServerResponse in place, we've created a common statically typed-model between the front and backend of our project!

As you might have guessed, the names and pathing of our function .ts files does matter in our case. There's a few simple rules to adhere to when creating function endpoints

  • netlify/functions/ping.ts would receive HTTP request matching the route /.netlify/functions/ping
  • netlify/functions/ping/index.ts would also receive HTTP request matching the route /.netlify/functions/ping
  • netlify/functions/ping/ping.ts would, again, also receive HTTP request matching the route /.netlify/functions/ping

Now that we have our first function in place, let's go ahead and start our function server:

npm run functions:serve

and in the terminal, we should see a message like the following:

> scully-netlify-angular-example@0.0.0 netlify:serve
> netlify functions:serve

 Ignored general context env var: LANG (defined in process)
 Loaded function ping.
 Functions server is listening on 9999

To test out that our functions are working correctly, let's startup Postman and send a request to http://localhost:9999/.netlify/functions/ping and see what we get:

Postman ping response

We've got a response! Now that our GET endpoint is working, let's add a POST endpoint that will receive requests with data in the body and return data based on it:

netlify/functions/data/index.ts

import { Handler } from '@netlify/functions'
import {
    getErrorResponse,
    getHeaders,
    HttpStatus,
    isWhitelistedDomain,
} from '../../lib'
import { ServerResponse } from 'types'

const handler: Handler = async (event) => {
    const origin = event.headers.origin ?? ''

    if (!isWhitelistedDomain(origin)) {
        return getErrorResponse(HttpStatus.Forbidden)
    }

    const method = event.httpMethod.toLowerCase()

    if (method !== 'options' && method !== 'post') {
        return getErrorResponse(HttpStatus.MethodNotAllowed)
    }

    /**
     * Because we have a more complex request (JSON being sent over the wire),
     * an options call is made by the Angular HTTP client service to check with our function
     * that it's okay to receive requests in the current context.
     */
    if (method === 'options') {
        return {
            statusCode: 200,
            headers: getHeaders(origin),
        }
    }

    if (event.body) {
        const requestBody: { name: string } = JSON.parse(event.body)

        const response: ServerResponse = {
            success: true,
            message: `Hello ${requestBody.name}!`,
        }

        return {
            statusCode: 200,
            body: JSON.stringify(response),
            headers: getHeaders(origin),
        }
    }

    return getErrorResponse(HttpStatus.InternalServerError)
}

export { handler }

Utilizing our helper methods again, since this is a POST request with more complex request headers (as we can inspect in the network tab attached for us using HttpClient), the client will make a pre-flight OPTIONS call to our function endpoint to essentially ask the question, "hey, this client application wants to make a request to you, is that cool?". We kick out any requests that are not POSTs or OPTIONs (again, normal HTTP framework code would take care of this for you, but we're running a bare bones server here) and accept the POST request body, returning the value of the name attribute given to us in a simple greeting. By adding this function with our server still running, Netlify recognizes a new function was added, and then compiles the output and serves it up under the /.netlify/functions/data endpoint. Let's give it a try with Postman:

Postman data response

Nice! Now with our functions fully operational and our server running, let's start our Angular server (if it wasn't already running), and watch the magic happen:

Angular Netlify functions

We've got ourselves a working solution! So far we've spun up our Angular application that calls into our serverless functions, but we're missing one final piece to truly be considered JAMstack: Scully.

Adding Scully to the project

To get started, the Scully team has made it dead simple to add static site generation to our Angular apps with a simple schematic. With the terminal open, let's run said schematic:

ng add @scullyio/init

The schematic will add a few Scully dependencies to our project, as well as generate a scully.{{projectName}}. config.ts file with the following:

import { ScullyConfig } from '@scullyio/scully'

/** this loads the default render plugin, remove when switching to something else. */
import '@scullyio/scully-plugin-puppeteer'

export const config: ScullyConfig = {
    projectRoot: './src',
    projectName: 'scully-netlify-angular-example',
    outDir: './dist/static',
    routes: {},
}

To make things easy on us, let's add a few build scripts to our scripts section in our package.json:

{
    "scripts": {
        // ...other scripts
        "build:scully": "cross-env NODE_ENV=scully ng build -c scully && npx scully --scanRoutes",
        "build:production": "cross-env NODE_ENV=production ng build -c production && npx scully --scanRoutes",
        "scully": "npx scully --",
        "scully:serve": "npx scully serve --"
    }
}

I'm using cross-env to assist with Tailwind purging, but as of this writing seems to no longer be needed for Tailwind projects targeting version 3 and up. With our scripts in place, let's go ahead and run the Scully command to generate a static version of our Angular app:

npm run scully

Once the Scully build completes, take a look at your /dist output folder. We should see a dist/static folder with ping and data subdirectories that contain single index.html files. The Scully dev server will serve these static HTML files with all of our Angular code functionality fully intact. Pretty neat, huh? Let's run the dev server:

npm run scully:serve

and once the server is spun up, if we navigate to localhost:1668 in our browser, we should see the same page that is being served by our usual ng serve command! Inspecting the page, we see a fully generated static HTML page, markup and all.

Putting it all together

With our Scully server running, we should still be able to hit our Netlify functions server (assuming it's still running), receiving the same responses we saw from serving the de-Scully'd version of our application. While it may not seem like much, let's take a second to realize what's going on:

  • We have a serverless backend stood up ready to receive HTTP requests and do any server related processing with zero backend frameworks in place
  • We have a fully static version of our Angular application running in the browser optimized for SEO and indexing by search engines
  • To top it off, we're doing all of this within the same repository code!

While we're only scratching the surface of what's possible with Angular, Scully, and Netlify Functions, the DX offered by these tools allows us to quickly develop and ship static websites with rich functionality and data fetching capability, all without implementing an entirely separate API layer in another framework. Our solution is also fully ready to deploy to Netlify, where our serverless functions will kick in, handling requests from our static Angular app deployed to the DSN of our choice. If you're looking to embrace the JAMstack with Angular, take Scully and Netlify Functions for a spin!

Not currently listening