Interactive Angular Islands with Sitecore and Astro

Anton Tishchenko
Anton Tishchenko
Cover Image for Interactive Angular Islands with Sitecore and Astro

We have already talked about the integration of React and Vue with Astro Sitecore JSS. Angular is the third. It is still quite popular among the existing Sitecore implementations. And it is definitely not dead. The new version of Angular was released last week with a bunch of awesome features. One of the features is Static Site Generation(SSG). It is a good news. Once Sitecore updates Sitecore JavaScript Rendering SDK for Angular to the 17 version, they most probably add SSG support as well. And we will have one more official Sitecore JavaScript Rendering SDK that supports SSG out of the box.

But, if you don’t want to wait or try to update JSS packages to Angular v17 by yourself, here is another way: use Astro. Astro doesn’t have official support of Angular for interactive islands. But it has community support! Many thanks to the Analog team and especially to Brandon Roberts, who made running Angular components inside Astro possible.

We tried how it works with our Sitecore Astro integration. And it works with one limitation: you can’t use placeholders inside Angular components. Details and explanations are below.

Challenge

Sitecore Angular SDK uses NgModule in its implementation. That approach worked for years. A few years ago Angular had added the standalone approach. It is recommended to use a standalone approach for new sites. And @analogjs/astro-angular, which we will use for integration supports only standalone mode. But that doesn’t mean that we can’t use Angular with Astro and Sitecore. We can, but with some limitations.

Installation

Installation and configuration of @analogjs/astro-angular are very similar to officially supported frameworks. We need to install npm packages:

# Using NPM
npx astro add @analogjs/astro-angular
# Using Yarn
yarn astro add @analogjs/astro-angular
# Using PNPM
pnpm astro add @analogjs/astro-angular

Add configuration to astro.config.mjs:

import { defineConfig } from 'astro/config';
import angular from '@analogjs/astro-angular';

export default defineConfig({
  integrations: [angular()],
});

And put an additional file for TypeScript support tsconfig.app.json:

{
  "extends": "./tsconfig.json",
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "noEmit": false,
    "target": "es2020",
    "module": "es2020",
    "lib": ["es2020", "dom"],
    "skipLibCheck": true
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true,
    "allowJs": false
  },
  "files": [],
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

Simple component

Now, we are ready to use standalone Angular components.

<div className="flex">
  <div>
    Count: {{count}}
  </div>
  <button (click)="count = count + 1" class="btn btn-primary m-2">
    Increment
  </button>
  <button (click)="count = count - 1" class="btn btn-primary m-2">
    Decrement
  </button>
</div>
import { Component } from '@angular/core';

@Component({
  selector: 'app-counter',
  templateUrl: './counter.component.html',
  standalone: true,
})
export default class CounterComponent {
  count!: number;

  ngOnInit() {
    this.count = 0;
  }
}
---
import StyleguideSpecimen from '../styleguide/Styleguide-Specimen.astro';
import CounterComponent from './angular/Counter/counter.component';

---
<StyleguideSpecimen route={Astro.props.route} e2eId="styleguide-integration-angular-simple">
  <CounterComponent client:load/>
</StyleguideSpecimen>

Sitecore fields

Now, let’s try the component with the Sitecore field.

<div class="contentBlock">
  <h6 class="contentTitle" *scText="fields?.heading"></h6>
  <div class="contentDescription" *scRichText="fields?.description"></div>
</div>
import { NgIf } from '@angular/common';
import type { ComponentRendering } from '@sitecore-jss/sitecore-jss-angular';
import { Component, Input } from '@angular/core';
import { TextDirective } from '@sitecore-jss/sitecore-jss-angular';
import { RichTextDirective } from '@sitecore-jss/sitecore-jss-angular';

@Component({
  standalone: true,
  selector: 'app-content-block',
  templateUrl: './content-block.component.html',
  imports: [NgIf, TextDirective, RichTextDirective]

})
export class ContentBlockComponent {
  @Input() fields: ComponentRendering | undefined;
}

It doesn’t work, because TextDirective and RichTextDirective directives are not standalone. But, fortunately for us Sitecore JSS is open source and these directives don’t have any dependencies inside the JSS library. We can copy directives with the addition of just one line standalone: true,

import {
    Directive,
    EmbeddedViewRef,
    Input,
    OnChanges,
    SimpleChanges,
    TemplateRef,
    ViewContainerRef,
  } from '@angular/core';
  import type { TextField } from './rendering-field';
  
  @Directive({
    standalone: true,
    selector: '[scText]',
  })
  export class TextDirective implements OnChanges {
    @Input('scTextEditable') editable = true;
  
    @Input('scTextEncode') encode = true;
  
    @Input('scText') field: TextField | undefined;
  
    private viewRef: EmbeddedViewRef<unknown> | undefined;
  
    constructor(private viewContainer: ViewContainerRef, private templateRef: TemplateRef<unknown>) {}
  
    ngOnChanges(changes: SimpleChanges) {
      if (changes.field || changes.editable || changes.encode) {
        if (!this.viewRef) {
          this.viewContainer.clear();
          this.viewRef = this.viewContainer.createEmbeddedView(this.templateRef);
        }
  
        this.updateView();
      }
    }
  
    private updateView() {
      const field = this.field;
      let editable = this.editable;
  
      if (!field || (!field.editable && (field.value === undefined || field.value === ''))) {
        return;
      }
  
      // can't use editable value if we want to output unencoded
      if (!this.encode) {
        editable = false;
      }
  
      const html = field.editable && editable ? field.editable : field.value;
      const setDangerously = (field.editable && editable) || !this.encode;
  
      this.viewRef?.rootNodes.forEach((node) => {
        if (setDangerously) {
          node.innerHTML = html;
        } else {
          node.textContent = html;
        }
      });
    }
  }

Now everything works as expected. We can render Sitecore fields. We can edit Sitecore fields in the Experience Editor.

Sitecore placeholders inside Angular components

Placeholder case, compared to directives is much more complex. Placeholder has too many dependencies to move it to our project as one file. Also, one of the core placeholder dependencies Components Factory relies on NgModule architecture. I am not saying that it is not possible, but it will require additional effort.

Conclusion

You can build an Astro Sitecore website with the usage of Angular for interactivity islands. The limitations will be the necessity to use standalone components and the inability to use placeholders inside interactive components.