Analog is a full-stack framework for Angular ala NextJS that offers server-side rendering, static site generation, and API routes.
Analog empowers Angular with server rendering tools making it optimal for public websites. Otherwise, Angular has often been neglected in favor of NextJS/NuxtJS for these purposes (due to the subpar SSR/SSG experience).
I recently migrated my course platform to AnalogJS to utilize the benefits of the framework and I will share with you every step of the migration and the road blocks on the way.
Server Side Rendering (SSR) vs. Static Site Generation (SSG) vs. Client Side Rendering (CSR)
First, to understand why a framework like Analog can be appealing, let’s understand the different kinds of rendering it provides and what each excels at.
Server Side Rendering (SSR):
- How it works: With SSR, the server dynamically generates the HTML for each page on the server and sends the fully rendered HTML to the client upon request.
- Advantages:
- Faster initial page loads: SSR can provide faster perceived page load times because the server sends pre-rendered HTML to the client, reducing the time required for the browser to render the page. Also if the frontend server, backend server, and database server are colocated on the same network it can reduce latency for each initial frontend request.
- Better SEO: Since the server sends fully rendered HTML to the client, SSR is generally better for SEO as search engine crawlers can easily parse the content.
- Security: SSR can offer better security for sensitive data and business logic since it keeps them on the server, reducing the risk of exposing critical information to the client.
- Disadvantages:
- More complicated development: You will have to handle both server code and client code in your application as well as handle hydration so you don’t double fetch data on the client.
- Harder debugging: You will have to both debug the server and client in case things goes wrong.
- Slower subsequent loads: While initial load can be faster subsequent loads can be slower than CSR as they require server processing and a server roundtrip.
- Use Cases: SSR is suitable for applications where SEO is critical, initial page load performance is essential, or security concerns require keeping sensitive logic on the server. Also, if the data is of a dynamic nature, eg. user-specific it it prefable over SSG.
Static Site Generation (SSG):
- How it works: With SSG, the entire site is pre-built at build time, and the server serves static HTML files to the client.
- Advantages:
- Blazing-fast performance: SSG can offer extremely fast page loads since the server only serves pre-built static HTML files, eliminating the need for server-side rendering or client-side JavaScript execution.
- Cost-effective scalability: Serving static files is highly scalable and can be done cheaply using content delivery networks (CDNs).
- Enhanced security: Since SSG sites are purely static files, there’s minimal server-side processing, reducing the attack surface.
- Use Cases: SSG is suitable for content-based sites, blogs, documentation sites, or any site where content doesn’t change frequently and where performance and simplicity are critical.
- Disadvantages:
- Harder to update: SSG is not well-suited for content that changes frequently or requires real-time updates since the site needs to be rebuilt each time the content changes. You can set up incremental static regeneration (ISR) that enables static files to be updated in the background if they are stale or don’t exist.
Client Side Rendering (CSR):
- How it works: With CSR, the server sends minimal HTML and JavaScript to the client, and the client-side JavaScript renders the page content dynamically in the browser.
- Advantages:
- Rich, interactive experiences: CSR allows for highly dynamic and interactive user experiences as the client-side JavaScript can handle updates without full page reloads.
- Flexibility: CSR is flexible for complex client-side interactions, real-time updates, and highly dynamic content.
- Use Cases: CSR is suitable for applications with heavy interactivity, real-time data updates, or where complex client-side logic is required, such as single-page applications (SPAs) or web apps.
Choosing the rendering approach for the course platform
The course platform is behind authentication using Firebase Auth. Some of this data is personalized to the user (eg. progress with the course) so this can’t be statically generated for everyone to use (that would require some sort of skeleton component and then load the progress using CSR driving up complexity). For this reason, we will server-side render the application.
The course platform tech stack and architecture
The course platform is based on the Firebase tools: Firestore, Firebase Auth, and Firebase Functions.
The architecture is a layered Nx monorepo architecture. It already contained an Angular app for the course portal with all the code contained in libraries so it was easy to reuse it for the Analog app. The architecture of the app course platform looks like this:
We see how the analog app can access the course-client
libs and reuse the code. Read more about my approach to Nx monorepo architecture here.
Analog, Vite, and Nitro
Analog is a Vite plugin and is using the Nitro server engine so Analog is built on the shoulders of giants that used the same technologies to build Nuxt.
Let’s dig deeper into each of these technologies.
Vite
Vite is frontend tooling used as a dev server and for bundling. It offers fast startup and HMR by loading source files over native ESM. It will load the Typescript files and have them directly interpreted in the browser, so no bundling is needed, which is normally why other bundling tools like Webpack are slow at startup and HMR. Dependencies change less often by nature and are bundled using esbuild which is 10x-100x faster than javascript-based bundlers like Webpack. For production builds Vite uses Rollup to bundle and avoid extra network roundtrips from nested imports. Vite has a plugin system that lets us eg. use Analog (Nitro based plugin).
Nitro
Nitro is the underlying server engine for Analog and is also used by Nuxt to provide a server framework for Vue. Analog is intending to do the same thing for Angular.
Nitro provides the features we know from Next.js and Nuxt such as; file-based routing, static site generation (SSG), caching tools, and server-side rendering (SSR) ideal for turning a SPA framework like Angular into a full-stack server framework that can. be used for public websites and gain a faster load and better SEO.
Analog vs. Angular Universal
You might be thinking; what about Angular Universal shouldn’t that solve the same problem? It should, but by building on top of Nitro the features such as SSR, SSG and the cache API you get out of the box are not something you get with Angular Universal. Angular Universal has been around for some time but because of the subbar developer experience frameworks like NextJS and NuxtJS have usually been favored instead of Angular Universal.
Authentication
We use Firebase Auth for authentication which provides a portal for managing authentication and libraries for handling authentication.
The course platform has a register page that registers users and checks in the CRM system if the user is a course member. Alternatively, I could have used passwordless authentication to send an email to the new student to register triggered by a webhook from Stripe (while verifying it got triggered by Stripe).
Check Firebase Auth authentication on the server side
Since we are going to server-side render the application, we need to check if the user is authenticated on the server side. The @angular/fire/auth
library only works client-side to check for authentication so we use the onAuthStateChanged
method to get the id token when the user gets authenticated and save it to a cookie we can later access on the server side:
// user.service.ts @Injectable({ providedIn: 'root', }) export class UserService { constructor( public afAuth: Auth, public ngZone: NgZone, private userServerService: UserServerService, cookieService: CookieService ) { if (isPlatformBrowser(this.platformId)) { this.afAuth.onAuthStateChanged(async (currentUser) => { if (currentUser) { const token = await currentUser.getIdToken(); userServerService.setIdToken(token); this.ngZone.run(() => { this.currentUser.set(currentUser); }); } }); } } }
We use the userServerService
for authenticating the user on the server side using the Firebase Admin SDK. It looks like this:
// user-server.service.ts @Injectable({ providedIn: 'root', }) export class UserServerService { constructor(private cookieService: SsrCookieService) {} private readonly SESSION_COOKIE_KEY = '__session'; setIdToken(token: string) { this.cookieService.set(this.SESSION_COOKIE_KEY, token); } getIdToken() { return this.cookieService.get(this.SESSION_COOKIE_KEY); } async getUserInfo(): Promise<DecodedIdToken | null> { const token = this.getIdToken(); if (!token) { return Promise.resolve(null); } try { const admin = (await import('firebase-admin')).default; const decodedToken = await admin.auth().verifyIdToken(token); return decodedToken; } catch (error) { console.error('Error while verifying token', error); return Promise.resolve(null); } } async isLoggedIn(): Promise<boolean> { return !!(await this.getUserInfo()); } }
We use the SsrCookieService
from ngx-cookie-service to handle cookies in our app. It has a library both for client and server-side handling of cookies.
Notice how we dynamically import the firebase-admin
package (so it’s not going to the client bundle) and using the verifyIdToken
to verify that the token (obtained from the cookie) is valid.
Setting up the server for reading cookies
In the UserServerService
, we use the ngx-cookie-service
to read the cookies (in the guards, eg. redirectIfLoggedOutServerGuard
) and we set the id token as a cookie client side in UserService
. For accessing cookies server side we had to set the REQUEST
and RESPONSE
providers in main.server.ts
:
// main.server.ts import { REQUEST as SSR_REQUEST } from 'ngx-cookie-service-ssr'; // ... const html = await renderApplication(bootstrap, { document, url, platformProviders: [ { provide: SSR_REQUEST, useValue: req }, { provide: 'RESPONSE', useValue: res }, ], });
Now the request information (including cookies) is available in the DI context which we use with the SsrCookieService
in the UserServerService
.
Creating the server auth guard
Now we can create a guard, running on the server side, checking if the user is logged in:
// redirect-if-logged-out-server.guard.ts export const redirectIfLoggedOutServerGuard: CanActivateFn = async () => { const platformId = inject(PLATFORM_ID); const router = inject(Router); const ngZone = inject(NgZone); if (isPlatformServer(platformId)) { const userServerService = inject(UserServerService); const isLoggedIn = await userServerService.isLoggedIn(); if (!isLoggedIn) { ngZone.run(() => { router.navigate(['login']); }); return false; } return true; } else { // For client-side, you might have a different logic. // You can handle it accordingly. return true; // Or any other logic for client-side } };
Note, the navigation has to happen in the NgZone
for proper change detection.
We now have a guard checking if we are authenticated on the server side and navigating to the login page in case we are not.
Login
The login is handled with the @angular/fire/auth
library which gives us functionality to log in/out and register users.
This was already implemented with the client app so we were able to reuse that:
// pages/(login).page.ts export const routeMeta: RouteMeta = { title: 'Login', canActivate: [redirectIfLoggedInServerGuard], providers: [], }; export default LoginComponent;
If we are logged in already we want to navigate to the courses page to access a course so we have the following guard for that:
// redirect-if-logged-in-server.guard.ts export const redirectIfLoggedInServerGuard: CanActivateFn = async () => { const platformId = inject(PLATFORM_ID); if (isPlatformServer(platformId)) { const userServerService = inject(UserServerService); const router = inject(Router); const ngZone = inject(NgZone); const isLoggedIn = await userServerService.isLoggedIn(); if (isLoggedIn) { ngZone.run(() => { router.navigate(['courses']); }) return false; } return true; } else { // For client-side, you might have a different logic. // You can handle it accordingly. return true; // Or any other logic for client-side } };
And we can now show the login page like this:
After we log in, we are taken to the courses page. Let’s build that.
Courses page
The idea behind the course platform is that it can support more than one course. The courses page shows all the courses the users can access and navigates them to a course on click.
This is the default page when you are logged in and we have a guard (redirectIfLoggedInServerGuard
) on the unauthenticated routes (eg. login page) taking us to this page when we are authenticated.
Auth server interceptor (Forward the Firebase Auth ID token)
To fetch the courses on the server side, we have to be authenticated and provide the Firebase Auth access token to the request. We have a server-side HTTP interceptor that gets the access token from the cookie and adds it to the HTTP header for server requests.
// auth-server.interceptor.ts export const authServerInterceptor: HttpInterceptorFn = ( req: HttpRequest<unknown>, next: HttpHandlerFn, platformId = inject(PLATFORM_ID), userServerService = inject(UserServerService) ) => { return from( handleAuthServerInterceptor(req, next, platformId, userServerService)! )!; }; async function handleAuthServerInterceptor( req: HttpRequest<unknown>, next: HttpHandlerFn, location: Object, userServerService: UserServerService ) { if (isPlatformServer(location)) { let headers = new HttpHeaders(); const token = userServerService.getIdToken(); headers = headers.set('Authorization', token); const userInfo = await userServerService.getUserInfo(); const tenantId = userInfo?.firebase?.tenant; if (tenantId) { headers = headers.set('Schoolid', tenantId); } const cookiedRequest = req.clone({ headers, }); return lastValueFrom(next(cookiedRequest)); } else { return lastValueFrom(next(req)); } }
Above, we read the Firebase Auth ID Token from the cookie (__session
) in the UserServerService
‘s getIdToken
and set it as the Authorization
header on each request. This request is received by the course service (Apollo GraphQL server) that checks this header and verifies the Firebase ID Token on each request for authorization:
// apps/course-service/src/app/server.ts const verifyToken = async ({ authorization, schoolid }) => { // TODO: disable for local env and set admin true const header = await admin .auth() .verifyIdToken(authorization) .then((decodedToken) => { if (decodedToken.firebase.tenant !== schoolid) { throw new AuthenticationError("User doesn't have access to school"); } return { ...decodedToken, schoolId: schoolid, } as AuthIdentity; }) .catch(function (error) { // Handle error throw new AuthenticationError('No Access: Invalid id token'); }); return header; };
We get the Firebase auth tenant
to determine which school they belong to (as the platform is meant to host courses for other schools than mine). We then set the Schoolid
HTTP header as the tenant id that is then checked along with the id token on the server on each request.
Course layout page
The course layout page is the page containing the list of sections (dropdown in sidebar), the lessons for current section (list of lessons in sidebar), and the current lesson (main content).
For this page, we need some route params: courseId
and selectedSectionId
, to get this we will follow Nitro’s route param conventions and will name the page: courses.[courseId].[selectedSectionId].page.ts
. The parenthesis ([]
) will automatically create route params.
This component is already in a library which we can reuse for the Analog page:
// courses.[courseId].[selectedSectionId].page.ts import { RouteMeta } from '@analogjs/router'; import { CourseLayoutComponent, courseServerResolver, } from '@course-platform/course-client/feature'; import { redirectIfLoggedOutServerGuard } from '@course-platform/course-client/shared/domain'; export const routeMeta: RouteMeta = { resolve: { courseServerResolver }, canActivate: [redirectIfLoggedOutServerGuard], }; export default CourseLayoutComponent;
Also in the routeMeta
we set the auth guard (redirectIfLoggedOutServerGuard
) and resolver courseServerResolver
.
The courseServerResolver
fetches the sections and lessons data on the server side:
// course.resolver.ts export const courseServerResolver: ResolveFn<null> = ( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, courseFacadeService = inject(CourseClientFacade) ) => { const platformId = inject(PLATFORM_ID); if (isPlatformServer(platformId)) { courseFacadeService.courseInitiated(); } return null; };
Lesson page
We create a file called [selectedLessonId].page.ts
within the courses.[courseId].[selectedSectionId]
folder so it will become the content of the courses.[courseId].[selectedSectionId].page.ts
layout page. That gives us the following page structure:
Like the other pages components, we have them in the libs/course-client/feature
library and we just reexport that component with some route metadata:
import { RouteMeta } from '@analogjs/router'; import { CourseContentComponent } from '@course-platform/course-client/feature'; import { LessonRouteData, LessonTypes, } from '@course-platform/shared/interfaces'; export const routeMeta: RouteMeta = { data: { lessonType: LessonTypes.Lesson } as LessonRouteData, }; export default CourseContentComponent;
The lessonType
route data is needed here to distinguish dynamic lesson pages and static pages action items and questions
which is in each section.
Hydrating the NgRx store from SSR
When initially implementing this, this was not performing very well and we saw a page spinner even though we were not doing any client-side requests. That’s because the NgRx store was not being hydrated on the client with the server state. We had to find a way to transfer the NgRx store state generated on the server to the client store.
In this pursuit, I found inspiration in the ngrx-universal-rehydrate package by Jay Bell. On the server side, it uses the TransferState
service to set the store state from the server before it is serialized (server data is converted to a string) to transfer over HTTP to the client (using the BEFORE_APP_SERIALIZED
hook).
On the client, we have a meta reducer that listens for the INIT
action and retrieves and merges the server state into the client store.
export function browserRehydrateReducer( platformId: Object, _transferStateService: TransferState, config: RehydrationRootConfig ): MetaReducer<unknown> { const isBrowser = isPlatformBrowser(platformId); if (isBrowser) { /** * This will grab from the transferred state all of the state keys created by this library for the root * and any features that were added */ const statesTransferred = _transferStateService.get( REHYDRATE_TRANSFER_STATE, undefined ); /** * Only return a reducer that will attempt to rehydrate the state if there were states transferred to begin with */ if (statesTransferred) { return (reducer) => (state, action) => { if (action.type === INIT) { const merged = mergeStates(state, statesTransferred, config.mergeStrategy) || {}; return reducer(merged, action); } return reducer(state, action); }; } } /** * If the app is not in the browser or if there were no transferred states then return a meta reducer * that does not don anything to the state */ return (reducer) => (state, action) => reducer(state, action); }
That way, the client store is initialized/hydrated with the server store state.
Also, we made sure to only trigger the fetching actions on the server:
export const courseServerResolver: ResolveFn<null> = ( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, courseFacadeService = inject(CourseClientFacade) ) => { const platformId = inject(PLATFORM_ID); if (isPlatformServer(platformId)) { courseFacadeService.courseInitiated(); } return null; };
Caveats
The migration wasn’t without a few road bumps on the way. Here are a few of them.
Problems with SSR on dependencies
As default, Vite doesn’t transform dependencies and expects them to be in ESM format. Some dependencies where not available in an ESM format so we had to add them to vite.config.ts
to make sure they were being transpiled:
// vite.config.ts export default defineConfig(({ mode }) => { // ... ssr: { noExternal: [ 'rxfire/**', '@ngx-translate/**', 'ngx-cookie-service/**', 'ngx-cookie-service-ssr/**', 'firebase-admin/**', 'firebase/**', '@apollo/client/**' ], }, }
One of the problematic packages was the rxfire package which doesn’t correctly expose the ESM module (package.json of package needs exports": { "import": "./index.esm.js"}
) and will instead be interpreted as a CommonJS module which Vite has to transform to ESM by adding the package to ssr.noExternal
.
Firebase passing session cookie from firebase hosting rewrite to function
You have to use the __session
cookie to set the Firebase Auth id token for it to be forwarded from the hosting site to the function on the rewrites.
Deploying the Analog app to Firebase (functions and hosting)
We are deploying the server part to Firebase functions and are deploying static content to Firebase hosting with a rewrite rule to target the server function (when no static content hit):
The firebase config is:
{ "functions": { "source": "server" }, "hosting": [ { "site": "course-platform-analog", "public": "public", "cleanUrls": true, "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "rewrites": [{ "source": "**", "function": "server" }] } ] }
Was the migration from Angular to Analog worth it?
Now we migrated the course platform to Analog, let’s consider if it was worth it.
Analog excels in providing SSR and SSG for Angular applications which is especially relevant for public pages where SEO is important such as blogs. If the course portal didn’t contain personalized information such as a progress bar tracker, the same content could be statically generated and reused across all users making the presentation very fast.
For this case, almost all the pages are authenticated as it is a paid course platform, so there is no need for SEO. Using Analog and SSR does increase the complexity for the developer as we had to handle certain things differently on the server and certain libraries only work on the client eg. @angular/fire/auth
.
Joshua Morony made a video about converting his course platform to Analog and his course platform is more suited for SSG as it doesn’t contain user-specific content but is is more like a blog protected by auth (using Cloud functions JWT checker).
The lightouse results after the migration to Analog:
And with plain Angular SPA it was:
We see the improved performance from the server-side rendered page + the hydration techniques we implemented.
Conclusion
Analog is a great meta-framework, especially for Angular apps that are looking to do SSG and SSR and need the public pages to be pre-rendered for SEO and performance. For a course platform like this, the business case of the migration is more questionable. As even though it was improving the load time performance, adding a server element to Angular development drives up complexity (eg. the need for hydration and server-side conditional code) and there is more room for mistakes.
Regardless it was a great learning experience and Analog looks like a promising alternative to Nuxt and Next.JS, especially for companies that are already invested in Angular.
Acknowledgments
While doing this migration I got great support overcoming caveats on the way from Brandon Roberts and the Analog community in Discord.
Do you want to become an Angular architect? Check out Angular Architect Accelerator.