Build Jamstack site with Angular and Scully

How to build Jamstack site with Angular and Scully

By Ondrej PolesnyJan 12, 2021

The Gatsby and Next.js feature development race is nice to watch if you’re a React developer, but what about our Angular friends? Scully is the first and only static site generator for Angular, although it works completely differently than any other generator.

In this article, I will explain how to implement a website in Angular, install Scully, and configure it to generate all pages as static files. I will also touch on deployment options and comparison with other generators.

What is Angular, and why developers use it

Angular is one of the most used JavaScript frameworks together with React and Vue. Not many beginners choose Angular due to its objectively steepest learning curve. For the same reason, it took me a few years to try and implement my first Angular site, and I did not enjoy the process at all. However, over time you begin to see the benefits of Angular such as strictly defined project structure, great support of TypeScript, modular scopes, and others. If you’re used to Angular, you’ll be able to quickly get up to speed in any project that is presented to you, which is probably why Angular is so popular in large projects and enterprise apps.

What is Scully

Scully is a static site generator for Angular. If you’ve implemented a Jamstack site before and think you know how SSGs work, let me stop you right there. Scully works entirely differently. Typically, static site generators give you a framework, a project structure, and you build your app on top of that. Not Scully. Scully takes your compiled Angular app as input and asks you to provide a list of URLs. Paths you want to generate as static files. It then runs your app, hits every path, and stores the output in an HTML file.

Building a Jamstack app with Angular

So how do we build a Jamstack app with Angular? I’ll first explain some of the Angular basics you will definitely stumble upon and then show how to configure Scully. Every described component in this article contains a link to its implementation on GitHub.

Modules and components

Angular comes with a concept of modules and components. A component can be a menu, header, footer, any scoped piece of code and logic. The component always needs to belong to a module. Modules typically contain components related to each other and to some specific functionality. Like ComponentsModule, AppRoutingModule, ScullyLibModule, or AppModule, which is the main module of every Angular app.

Kentico Kontent

By default, all the components can only be used in the scope of their parent module. If you want to use a component outside of its module, the module needs to export it.

...
@NgModule({
  declarations: [ SidebarComponent, ArticleComponent, MenuComponent, LinksComponent ],
  imports: [
    CommonModule,
    RouterModule
  ],
  exports: [
    SidebarComponent,
    ArticleComponent,
    MenuComponent,
    LinksComponent
  ]
})
export class ComponentsModule { }

So if we want to use the MenuComponent in our app, we first need the ComponentsModule to export MenuComponent, then import the ComponentsModule in AppModule, and after that import the MenuComponent in a place where we want to render it.

Routing module

Routing is handled by a special module AppRoutingModule that is imported into AppModule. You can create it using the following command:

ng generate module app-routing —flat —module=app

Every route needs to define a URL path and a component that will be responsible for rendering output. For example, if you want to create a blog post page under URL https://your.domain/articles/url-of-the-article, the route will look like this:

{path: '/articles/:slug', component: FullArticleComponent}

The FullArticleComponent needs to be imported into the AppRoutingModule. This way, all requests to URLs starting with /articles/ will be forwarded to the FullArticleComponent.

Components and templating basics

Now that we have modules and routing in place, we can move forward to components and rendering markup. Components are split into multiple files depending on their purpose. They typically feature logic (code) and markup:

./article.component.ts
./article.component.html

But there can also be other files. For example, .sass file for style definitions.

./article.component.sass

You can generate components using the following command:

ng generate component Article --module ComponentsModule

The code part of the component is responsible for preparing data for rendering. It contains functions for data gathering and transforming. In my case, most of the time, I used a service that gathered data from Kentico Kontent, and a Moment.js library to transform dates.

...
@Component({
  selector: 'full-article',
  templateUrl: './full-article.component.html'
})
export class FullArticleComponent implements OnInit {
  constructor(protected kontentService: KontentService, ...) {
    this.moment = moment;
  }
 ...
 public article: Article;
  moment: any;
 ...
  ngOnInit(): void {
    this.loadData();
   ...
  }
  loadData(): void {
    this.kontentService.deliveryClient
      .item<Author>('author')
     ...
      .then(response => {
          this.article = response.items[0];
        })
  }
}

All public members of the component class are available to the markup.

When it comes to the markup part, every platform handles rendering data differently. However, we’re almost always trying to achieve the same goals. Take a look at the table below for Angular reference:


SyntaxExample
Outputting data into HTML{{ variable }}{{ metadata.subtitle.value }}
Outputting data into HTML attributes< ... [attr.name]="variable"><a [attr.href]="'https://twitter.com/' + author.twitter.value">
Iterating over data sets< ... *ngFor="let item of collectionVariable"><article *ngFor="let article of articles" ... >
Rendering conditional markup<div ... *ngIf="variable !== null"><p *ngIf="metadata" class="sidebar__copyright"> ... </p>
Adding class attribute<div class="classname"><div class="sidebar__inner">
Passing data to child components<component [componentVariable]="variable"><links [author]="author">

The entry component is typically the HomeComponent as it’s defined in the default empty route in AppRoutingModule. The HTML base frame is in the index.html file located in the src folder under the project root.

Adding DeliveryClient and environment files

Now, how do we add some real content to our project?

I already touched on the data gathering a bit above. For any outside communication, you will need an environmental file. It’s used to store URLs of API endpoints, the access keys, and generally any sensitive data that can vary among environments. Most commonly, it’s called .env and is placed in the root of a project. In Angular, the file is called environment.ts and is stored in a separate folder /environments.

I used the file to store the project ID of a Kontent project that contains all content I want to use on the site:

export const environment = {
  production: false,
  kontent: {
    deliveryProjectId: 'fe1e198a-96eb-01ea-a4c8-477c331d5ed8'
  }
};

To facilitate outside communication, you can create injectable helper classes—services. I created the KontentService which instantiates the Kontent SDK’s DeliveryClient:

@Injectable({
    providedIn: 'root'
})
export class KontentService {
    public deliveryClient: IDeliveryClient;
    constructor(angularHttpService: AngularHttpService) {
        this.deliveryClient = new DeliveryClient({
            projectId: environment.kontent.deliveryProjectId,
            httpService: angularHttpService,
            typeResolvers: [
                new TypeResolver('article', () => new Article()),
                new TypeResolver('author', () => new Author()),
                new TypeResolver('category', () => new Category()),
                new TypeResolver('content_page', () => new ContentPage()),
                new TypeResolver('menu_item', () => new MenuItem()),
                new TypeResolver('site_metadata', () => new SiteMetadata()),
                new TypeResolver('tag', () => new Tag())
            ]

        })

    }

}

Note that I’m using AngularHttpService instead of standard HttpService. Otherwise Angular would not wait until all data is fetched when pre-rendering the site.

You can use the injectable service anywhere in your components by adding it to the component class’s constructor:

constructor(protected kontentService: KontentService, ...) {
    this.moment = moment;
  }

The protected keyword automatically creates a class member so you can directly use the service in the class functions:

loadData(): void {
    this.kontentService.deliveryClient
      .item<Author>('author')
     ...
      .then(response => {
          this.article = response.items[0];
        })
  }

Routing to the same component with a different URL

In your components, it’s best to get data in the ngOnInit function. However, if the component processes multiple URLs, you need to subscribe to the navigation events and trigger the data gathering manually to ensure the component renders the right data. Look at these two URLs:

https://your.domain/articles/angular-is-cool
https://your.domain/articles/scully-is-cool

They both get processed by the component FullArticleComponent. The first article will be rendered correctly, but when you click a link to see the other, Angular won’t trigger the ngOnInit function, and you won’t get any update. To mitigate this, make sure you’re subscribed to navigation event:

ngOnInit(): void {
   this.loadData(); // load data in extra function
   this.router.events.pipe(
     filter((event: RouterEvent) => event instanceof NavigationEnd)
   ).subscribe(() => {
     this.loadData();
   });
}

Note: This is an Angular feature. If you plan to use only the static site without the Angular bundle, you can skip this as Scully hits every path separately.

Configuring Scully to pre-render the site

All the steps above describe how to create an Angular site. Unlike other static site generators, Scully runs on top of the built site, hits every path, and stores the generated source code in an HTML file. You only need to tell Scully what are the paths it should hit.

The routes you defined in AppRoutingModule are dynamic. For example, path '/articles/:slug' can match one, two, but also ten thousand articles depending on how many you’ve added to the headless CMS. Scully is not crawling your website trying to find all links and pages but requires you to list all the paths you want to be pre-rendered.

Let’s add Scully to the project:

ng add @scullyio/init

And open the configuration file called scully.{project name}.config.ts. The important part is the definition of routes. You need to provide a list of paths for every route defined in AppRoutingModule that you want to be pre-rendered. If the route does not exactly match its definition in AppRoutingModule, it will be ignored.

There are multiple ways to generate paths for Scully depending on where you’re getting the data from. Here we are using a headless CMS and getting the data from API. Therefore, we first need to process the API response using the resultsHandler function and then specify which property holds the URL slug of each item. Feel free to copy-paste the API URL in your browser to see the raw data.

export const config: ScullyConfig = {
  projectRoot: "./src",
 ...
  routes: {
    ...
    '/articles/:slug': {
      type: 'json',
      slug: {
        url: `https://deliver.kontent.ai/fe1e198a-96eb-01ea-a4c8-477c331d5ed8/items?system.type=article&elements=slug`,
        property: "elements.slug.value",
        resultsHandler: (raw) => raw.items
      }
    }
  }
};

Generating pre-rendered site

Scully runs on top of the built Angular site, so first you’ll need to run:

ng build

And then execute Scully:

npm run scully

This will work if you’ve implemented and configured everything correctly. But that hardly ever happens on the first try. :-) So let’s have a look at the commands we use most often during development.

You can test your Angular app with hot reload using:

ng serve

Once your site is ready, don’t forget to build it. Then when you’re trying to configure the routes for Scully, you can run:

npm run scully -- -- scanRoutes

This will ask Scully to check the route config and save the generated routes in /dist/static/assets/scully-routes.json so you can check if the list matches your expectations.

And finally, you can check the pre-rendered pages by running the Scully server:

npm run scully:serve

Building a Scully site for production

You may have noticed that the pre-rendered site is also downloading the full Angular bundle and once it’s ready, your site suddenly becomes a full Angular SPA. That is intended behavior. Scully ensures that visitors see the content of your site as soon as possible while the bundle is downloaded later.

If you want to change this behavior and keep only static files, you can install a plugin that disables Angular:

npm i scully-plugin-disable-angular

And add these lines into scully.{project name}.config.ts:

const {DisableAngular} = require('scully-plugin-disable-angular');
const postRenderers = [DisableAngular];
setPluginConfig(DisableAngular, 'render', { removeState: true });
export const config: ScullyConfig = {
  defaultPostRenderers: postRenderers,  // for all routes
 ...
}

To prepare your site for production, you need to:

  • Clone the environment file and name it environment.prod.ts
  • Run your build with a prod flag:

npm run build – --prod && npm run scully

The output folder is defined in the scully.{project name}.config.ts file and is by default /dist/static. Don’t forget to set the publish directory on your hosting platform. If you’re deploying on Netlify, you’ll find the setting in Build & deploy section under Build settings:

Kentico Kontent

Conclusion

And that’s it. Once the build finishes, your site is live and pre-generated.

If you get stuck on some part of the build process or want to get a head start, check out the Scully starter site for Kontent repo on GitHub. Good luck! :)

Written by
Ondrej Polesny

As Developer Evangelist, I specialize in the impossible. When something is too complicated, requires too much effort, or is just simply not possible, just give me a few days and we will see :).

More articles from Ondrej

Subscribe to Kentico Kontent Newsletter

Stay in the loop. Get the hottest updates while they’re fresh!