Scully is a “Static Site Generator for Angular apps” that enables Angular apps to pre-render pages with dynamic content to improve performance metrics like First Contentful Paint (FCP), Time to Interactive (TTI), and others which are used by Search Engines to rank your website.
But is Scully the right tool for your Angular E-Commerce Application?
Let’s find out if it suits your needs. SPOILER ALERT: yes, it probably does.
How does Scully work?
Scully provides an additional step after Angular’s build
step, which will identify your application’s routes to be rendered, then serve your application and launch a browser instance to navigate through selected routes. When the browser finishes rendering each route, Scully copies its rendered content and saves everything in HTML files inside dist
folder.
If you want to know how Scully works behind the curtains in more detail, take a look at The Scully Process page in the official documentation.
How Does Scully Improve An E-Commerce Application?
Search Engine Optimization (SEO) is a must for any website nowadays, especially for E-Commerce Apps.
Scully will help your E-Commerce Application rank higher on search results by statically rendering each product page, making the application load faster for users and search engines alike. Performance metrics used by search engines will also improve as a result of Scully’s pre-rendering process.
The performance improvements will also lead to lower bounce rates and higher conversion rates, as users will have a better experience while navigating.
In other words, Scully essentially caches the application with statically served files, improving load time, and making processing your application easier on browsers and search engines, since there will be no javascript functions to run and no need to make external HTTP calls to fetch data.
Installation
To install Scully, run ng add @scullyio/init
.
Scully will then ask which route renderer you would like to use. As of 2022, we recommend picking Puppeteer
, since other options are currently in beta.
Once installation is done, you’ll notice there’s a new Scully config file, scully.[project].config.ts
, and app.module.ts
now imports ScullyLibModule
.
To match the usability of ng serve
, when developing you’ll need to run two terminals with the following commands:
-
ng build --watch
- whenever there’s a file change, it will trigger the build step -
npx scully --scanRoutes --watch
- whenever the build files generated byng build --watch
change, it will trigger Scully's build step
If you need to run both the static and regular builds at the same time, you can use npx scully serve
, but you won’t have automatic updates when there are changes in builds.
Dynamic Routes
Scully provides a pre-build step where it can fetch information and decide which routes your application will render based on any logic you see fit.
In order to do that, you need to go to the ./scully.[project].config.ts
file and edit the routes
property inside the exported config
object.
The routes
property is an object that can configure multiple routes, and each route can have different logic to decide which children routes will be rendered.
In the example below, we have a /product/:slug
route, and Scully will fetch the url
and run the resultsHandler
function with the response data. resultsHandler
must return a list of objects with the property defined in the property property
, which in this example has the slug
value. More information at the official documentation.
Scully will then find out which routes should be rendered by replacing :slug
in the route /product/:slug
with the slug
property value for each object in resultsHandler
returned array.
export const config: ScullyConfig = {
projectRoot: './src',
projectName: 'PROJECT-NAME-HERE',
outDir: './dist/static',
routes: {
'/product/:slug': {
type: 'json',
slug: {
url: 'https://PRODUCT-API-HERE/products',
property: 'slug',
resultsHandler: (data) => {
// you can process anything here,
// but you must return a list of objects
// that have a 'slug' property, as defined above in line 8
},
},
},
},
};
Making API Data Static
Caching API calls will make your application faster and less reliant on servers availability. With useScullyTransferState
method you are able to cache the results of Observables. This means, however, that to update the data statically served by useScullyTransferState
you will need to trigger a new Scully build and deploy pipeline.
The first argument of the useScullyTransferState
method is the index that will be used to get the data at runtime.
The second argument expects an Observable, which means you are not limited to caching API calls, you can cache heavy operation Observables too.
// some service method
getCatalog(): Observable<Catalog> {
return this.transferState.useScullyTransferState(
'catalog',
this.http.get<Catalog>('https://CATALOG-URL')
);
}
Not All API Requests Should Be Cached
Should you wrap every Observable with useScullyTransferState
? Definitely not. There might be cases where observables should only run at runtime and don’t need to be cached.
Good examples include observables that rely on login state, like cart
or user
data, or when you need to hide or show specific parts of your application in the static generated version only.
// ./src/app/app.component.ts example
@Component({
selector: 'app-root',
template: '
<nav>
<app-menu></app-menu>
<app-cart *ngIf="!isScullyRunning"></app-cart>
</nav>
<router-outlet></router-outlet>
',
})
export class AppComponent {
readonly isScullyRunning: boolean = isScullyRunning();
}
Relying on Environment Files
When writing plugins or dealing with the Scully config file, you may need to import environment.prod.ts
or some other file.
By default, Scully only transpiles .ts
files inside the ./scully
folder. Fortunately, it’s rather simple to tell Scully that more files need to be transpiled to JavaScript.
In ./scully/tsconfig.json
file, add an include
property with the patterns/files you need. The ./**/**
pattern will match all files inside ./scully
folder, which is the default behavior if an include
property is not defined. Then, you only need to add specific files that exist outside of ./scully
folder.
// ./scully/tsconfig.json
{
"compileOnSave": false,
"compilerOptions": {
"esModuleInterop": true,
"importHelpers": false,
"lib": ["ES2019", "dom"],
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"target": "es2018",
"types": ["node"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"typeRoots": ["../node_modules/@types"],
"allowSyntheticDefaultImports": true
},
"include": ["./**/*", "../src/environments/environment.prod.ts"],
"exclude": ["./**/*spec.ts"]
}
Creating a Custom Plugin for Incremental Builds
Rendering all the pages each time a single product changes is a waste of time and resources. It might not have a huge impact when you have a small set of products, but what if your E-Commerce application has thousands of product pages to render?
Implementing incremental builds is an optimized solution where only changed content is re-rendered and deployed, saving you and your organization time and money in CI pipeline and deployments.
Scully has a very powerful plugin system that enables you to control the pre-render process. There’s more information about Scully plugins in Plugin Concept and Plugin Reference.
Below is an example of a plugin approach to render specific products by passing a list of IDs with the scully
command. Command example: npx scully --productIds=1,2,3
. This approach expects that the build and deploy pipeline are triggered by a system (like a CMS) with an awareness of what content has changed.
In ./scully/plugins/plugin.ts
, a product
plugin is registered as a router
plugin, then used in ./scully.[project].config.ts
file to configure how the routes in /product/:slug
are handled.
// ./scully/plugins/plugin.ts
import { HandledRoute, registerPlugin, httpGetJson } from '@scullyio/scully';
import { Product } from '../../src/app/product/product.model';
import { environment } from '../../src/environments/environment.prod';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
export const product = 'product';
const productRoutes = async (route?: string): Promise<HandledRoute[]> => {
const yarg = yargs(hideBin(process.argv));
const argv = await yarg.option('productIds', { type: 'string' }).argv;
// if there are --productIds being passed in npx scully --productIds=1,2,3
// then only generate static files for those products
// else fetch all products from API and map the returned IDs.
const productIds: string[] = argv.productIds
? argv.productIds.split(',')
: ((await httpGetJson(`${environment.api.url}/products`)) as Product[]).map(
(product) => product.id.toString()
);
const productRoutes: HandledRoute[] = productIds.map((id) => ({
route: `/product/${id}`,
}));
return Promise.resolve(productRoutes);
};
const validator = async () => [];
registerPlugin('router', product, productRoutes, validator);
You’ll also need to update the config file to use the product
plugin:
// ./scully.[project].config.ts
import './scully/plugins/plugin';
export const config: ScullyConfig = {
projectRoot: './src',
projectName: 'PROJECT-NAME-HERE',
outDir: './dist/static',
routes: {
'/product/:slug': {
type: 'product',
},
},
};
Sweet! Now when we run the Scully build with this plugin, Scully will select to pre-render either the product IDs passed as arguments - if there are any - or the returned product IDs of an API call to ${environment.api.url}/products
, meaning you have great control over which routes to render.
Scully is AWESOME!
We’ve seen how Scully can solve Angular E-Commerce applications' problems in a handful of ways.
There is little downside to using Scully. The main one is the need to run an extra step between build and deploy, but as shown in this article, you can lower the impact with incremental builds. Needless to say, if you care about lower bounce rates and increasing the likelihood your website will be a top result on search engines, Scully is your friend.
As a bonus, I’ve setup an E-Commerce example app (deployed with ng build && scully
) where I used Scully to pre-render the content so you can see how the output looks using your browser’s DevTools. And you can compare it with a second E-Commerce no-Scully example app (deployed with ng build
only), where the Scully pre-render step is not used. By the way, here's the GitHub repo.
If you need any assistance or you just want to chat with us, you can reach us through our Bitovi Community Discord.