diff --git a/.changeset/brave-otters-spend.md b/.changeset/brave-otters-spend.md new file mode 100644 index 00000000..244f2171 --- /dev/null +++ b/.changeset/brave-otters-spend.md @@ -0,0 +1,29 @@ +--- +'@o2s/blocks.featured-service-list': minor +'@o2s/blocks.notification-details': minor +'@o2s/blocks.notification-list': minor +'@o2s/blocks.payments-history': minor +'@o2s/blocks.payments-summary': minor +'@o2s/blocks.service-details': minor +'@o2s/blocks.article-search': minor +'@o2s/blocks.orders-summary': minor +'@o2s/blocks.ticket-details': minor +'@o2s/blocks.category-list': minor +'@o2s/blocks.order-details': minor +'@o2s/blocks.surveyjs-form': minor +'@o2s/blocks.ticket-recent': minor +'@o2s/blocks.article-list': minor +'@o2s/blocks.invoice-list': minor +'@o2s/blocks.service-list': minor +'@o2s/blocks.user-account': minor +'@o2s/blocks.quick-links': minor +'@o2s/blocks.ticket-list': minor +'@o2s/blocks.order-list': minor +'@o2s/blocks.category': minor +'@o2s/blocks.article': minor +'@o2s/blocks.faq': minor +'@o2s/frontend': minor +'@o2s/ui': minor +--- + +added support for prioritizing image rendering in order to disable lazyloading for images above the fold diff --git a/.changeset/chubby-candles-cut.md b/.changeset/chubby-candles-cut.md new file mode 100644 index 00000000..6419b7c5 --- /dev/null +++ b/.changeset/chubby-candles-cut.md @@ -0,0 +1,31 @@ +--- +'@o2s/blocks.featured-service-list': minor +'@o2s/blocks.notification-details': minor +'@o2s/blocks.notification-list': minor +'@o2s/blocks.payments-history': minor +'@o2s/blocks.payments-summary': minor +'@o2s/blocks.service-details': minor +'@o2s/blocks.article-search': minor +'@o2s/blocks.orders-summary': minor +'@o2s/blocks.ticket-details': minor +'@o2s/blocks.category-list': minor +'@o2s/blocks.order-details': minor +'@o2s/blocks.surveyjs-form': minor +'@o2s/blocks.ticket-recent': minor +'@o2s/blocks.article-list': minor +'@o2s/blocks.invoice-list': minor +'@o2s/blocks.service-list': minor +'@o2s/blocks.user-account': minor +'@o2s/integrations.mocked': minor +'@o2s/blocks.quick-links': minor +'@o2s/blocks.ticket-list': minor +'@o2s/blocks.order-list': minor +'@o2s/modules.surveyjs': minor +'@o2s/blocks.category': minor +'@o2s/blocks.article': minor +'@o2s/api-harmonization': minor +'@o2s/blocks.faq': minor +'@o2s/frontend': minor +--- + +made improvements to the way the code splitting to reduce the total size of JS bundles diff --git a/.changeset/stale-forks-pay.md b/.changeset/stale-forks-pay.md new file mode 100644 index 00000000..87e7d7b0 --- /dev/null +++ b/.changeset/stale-forks-pay.md @@ -0,0 +1,6 @@ +--- +'@o2s/frontend': minor +'@o2s/ui': minor +--- + +reduced JS bundle size by not moving to dynamic icon loading diff --git a/.gitattributes b/.gitattributes index 7bc07c75..92c5d525 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,8 @@ *.jpg binary *.jpeg binary *.png binary +*.gif binary +*.webp binary + +# Videos +*.mp4 binary diff --git a/apps/api-harmonization/package.json b/apps/api-harmonization/package.json index 1447dd27..377a8f09 100644 --- a/apps/api-harmonization/package.json +++ b/apps/api-harmonization/package.json @@ -7,8 +7,8 @@ "license": "MIT", "exports": { ".": "./src/index.ts", - "./blocks": "./src/blocks/index.ts", - "./modules": "./src/modules/index.ts" + "./blocks/*": "./src/blocks/*.ts", + "./modules/*": "./src/modules/*.ts" }, "scripts": { "dev": "cross-env NODE_ENV=development nest start --watch", @@ -42,7 +42,6 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "string-template": "^1.0.0", - "survey-core": "^2.0.9", "winston": "^3.17.0", "xmlbuilder2": "^3.1.1", "@o2s/blocks.faq": "*", diff --git a/apps/api-harmonization/src/modules/login-page/index.ts b/apps/api-harmonization/src/modules/login-page/index.ts index 37c7f4a1..0566ece4 100644 --- a/apps/api-harmonization/src/modules/login-page/index.ts +++ b/apps/api-harmonization/src/modules/login-page/index.ts @@ -1,4 +1,4 @@ -export const URL = '/login-page'; +export { URL } from './login-page.url'; export * as Model from './login-page.model'; export * as Request from './login-page.request'; diff --git a/apps/api-harmonization/src/modules/login-page/login-page.url.ts b/apps/api-harmonization/src/modules/login-page/login-page.url.ts new file mode 100644 index 00000000..89dbcc2c --- /dev/null +++ b/apps/api-harmonization/src/modules/login-page/login-page.url.ts @@ -0,0 +1 @@ +export const URL = '/login-page'; diff --git a/apps/api-harmonization/src/modules/not-found-page/index.ts b/apps/api-harmonization/src/modules/not-found-page/index.ts index 4b050b76..24baed79 100644 --- a/apps/api-harmonization/src/modules/not-found-page/index.ts +++ b/apps/api-harmonization/src/modules/not-found-page/index.ts @@ -1,4 +1,4 @@ -export const URL = '/not-found-page'; +export { URL } from './not-found-page.url'; export * as Model from './not-found-page.model'; export * as Request from './not-found-page.request'; diff --git a/apps/api-harmonization/src/modules/not-found-page/not-found-page.url.ts b/apps/api-harmonization/src/modules/not-found-page/not-found-page.url.ts new file mode 100644 index 00000000..926f491b --- /dev/null +++ b/apps/api-harmonization/src/modules/not-found-page/not-found-page.url.ts @@ -0,0 +1 @@ +export const URL = '/not-found-page'; diff --git a/apps/api-harmonization/src/modules/organizations/index.ts b/apps/api-harmonization/src/modules/organizations/index.ts index 0e0a23aa..2a7a1014 100644 --- a/apps/api-harmonization/src/modules/organizations/index.ts +++ b/apps/api-harmonization/src/modules/organizations/index.ts @@ -1,4 +1,4 @@ -export const URL = '/organization-list'; +export { URL } from './organizations.url'; export * as Model from './organizations.model'; export * as Request from './organizations.request'; diff --git a/apps/api-harmonization/src/modules/organizations/organizations.url.ts b/apps/api-harmonization/src/modules/organizations/organizations.url.ts new file mode 100644 index 00000000..fce39e95 --- /dev/null +++ b/apps/api-harmonization/src/modules/organizations/organizations.url.ts @@ -0,0 +1 @@ +export const URL = '/organization-list'; diff --git a/apps/api-harmonization/src/modules/page/index.ts b/apps/api-harmonization/src/modules/page/index.ts index cb344b5e..f2962398 100644 --- a/apps/api-harmonization/src/modules/page/index.ts +++ b/apps/api-harmonization/src/modules/page/index.ts @@ -1,4 +1,4 @@ -export const URL = '/page'; +export { URL } from './page.url'; export * as Model from './page.model'; export * as Request from './page.request'; diff --git a/apps/api-harmonization/src/modules/page/page.url.ts b/apps/api-harmonization/src/modules/page/page.url.ts new file mode 100644 index 00000000..cf3a5ecd --- /dev/null +++ b/apps/api-harmonization/src/modules/page/page.url.ts @@ -0,0 +1 @@ +export const URL = '/page'; diff --git a/apps/api-harmonization/src/modules/routes/index.ts b/apps/api-harmonization/src/modules/routes/index.ts index cdf01a9d..5f100360 100644 --- a/apps/api-harmonization/src/modules/routes/index.ts +++ b/apps/api-harmonization/src/modules/routes/index.ts @@ -1,4 +1,4 @@ -export const URL = '/routes'; +export { URL } from './routes.url'; export * as Model from './routes.model'; export * as Request from './routes.request'; diff --git a/apps/api-harmonization/src/modules/routes/routes.url.ts b/apps/api-harmonization/src/modules/routes/routes.url.ts new file mode 100644 index 00000000..d40cdb86 --- /dev/null +++ b/apps/api-harmonization/src/modules/routes/routes.url.ts @@ -0,0 +1 @@ +export const URL = '/routes'; diff --git a/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md b/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md index 42307682..00274649 100644 --- a/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md +++ b/apps/docs/blog/articles/building-composable-frontends-with-strapi-and-nextjs/index.md @@ -1,6 +1,8 @@ --- slug: building-composable-frontends-with-strapi-and-nextjs title: 'Building composable frontends with Strapi and Next.js' +description: 'Learn how to build composable frontends using Strapi CMS and Next.js. Discover dynamic content modeling, reusable UI blocks, flexible layouts, and scalable component architecture for modern web applications.' +keywords: ['Strapi CMS', 'Next.js', 'composable frontend', 'headless CMS', 'content modeling', 'dynamic layouts', 'reusable components', 'UI blocks', 'content management', 'frontend architecture', 'component composition', 'page builder', 'content modeling', 'API integration', 'React components', 'TypeScript', 'frontend framework'] date: 2025-03-28 tags: [tech, integrations] authors: [marcin.krasowski] @@ -14,11 +16,11 @@ We’re building a frontend-first framework for composable customer portals – To support dynamic content, reusable UI blocks, and flexible layouts, we needed a CMS that gives developers control over structure while staying accessible to business users. That’s where **[Strapi](https://strapi.io/)** comes in. In O2S, we use it not just for managing page content, but also for defining layout templates, page structures, and component configurations. This approach helps us find the right balance between flexibility for editors and consistency in the frontend. + In this article, we’ll show how Strapi powers the content architecture behind our composable frontend, and how it integrates with Next.js to deliver dynamic pages, structured layouts, and scalable UI patterns. - ## Web content management in modern front-end apps One of the challenges that often comes with large-scale frontend applications is managing the content in a way that on one hand gives the content editors diff --git a/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/cumulative-layout-shift.gif b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/cumulative-layout-shift.gif new file mode 100644 index 00000000..fceac11b Binary files /dev/null and b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/cumulative-layout-shift.gif differ diff --git a/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/high-level-architecture.png b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/high-level-architecture.png new file mode 100644 index 00000000..7ab752fd Binary files /dev/null and b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/high-level-architecture.png differ diff --git a/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/high-level-architecture.svg b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/high-level-architecture.svg new file mode 100644 index 00000000..e679ce26 --- /dev/null +++ b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/high-level-architecture.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/index.md b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/index.md new file mode 100644 index 00000000..25376c08 --- /dev/null +++ b/apps/docs/blog/articles/ensuring-high-frontend-performance-in-composable-nextjs-apps/index.md @@ -0,0 +1,432 @@ +--- +slug: ensuring-high-frontend-performance-in-composable-nextjs-apps +title: Ensuring high frontend performance in composable Next.js apps +description: Learn proven strategies for optimizing frontend performance in composable Next.js applications. Discover server components, streaming, caching, and Core Web Vitals optimization techniques for enterprise-grade apps. +image: ./lighthouse-2.png +keywords: ['Next.js performance', 'composable architecture', 'frontend optimization', 'server components', 'Core Web Vitals', 'Lighthouse', 'React Suspense', 'streaming', 'performance optimization', 'enterprise frontend', 'API composition', 'caching strategies', 'image optimization', 'bundle splitting', 'FCP', 'LCP', 'CLS', 'TBT'] +date: 2025-10-06 +tags: [tech, performance] +authors: [marcin.krasowski] +toc_max_heading_level: 3 +hide_table_of_contents: false +--- + +# Ensuring high frontend performance in composable Next.js apps + +In today's web development landscape, composable architectures are gaining popularity for their flexibility and scalability. However, this approach introduces unique performance challenges. In this article, we will explore strategies and best practices for ensuring high frontend performance in composable applications, using [**Open Self Service**](https://www.openselfservice.com/) as a practical example. + +![lighthouse score.png](./lighthouse-2.png) + + + +Open Self Service is a new framework for building enterprise-grade frontend solutions. + +Our aim is to create an open-source set of tools that would allow building not only storefronts but different client-facing frontends, with the main focus on customer self-service apps. We want to be backend-agnostic and to some extent eliminate vendor lock-in, so that the frontends you build are safe from backend changes or upgrades. Composable architecture helps us to achieve all of this, so we need to introduce it before we show you how we deal with the performance challenges we faced. + +## Understanding composable architecture + +### What is a composable architecture? + +In a nutshell, composable architecture is an approach to building applications by assembling modular, independent components that work together to create a complete solution - not only in the context of frontend, but across the whole system architecture, especially backend components. In the context of Open Self Service, we implemented this architecture in the form of a framework that enables the integration of multiple API-based services to provide a seamless user experience. + +At its core, a few principles characterize composable architecture: + +- applications are built from discrete, **interchangeable components** that can be developed, deployed, and scaled independently, +- components communicate through **well-defined APIs** allowing for a wide flexibility in implementation details, +- **decoupled systems** (like frontend and backend components), which allows for each to evolve independently without affecting the other. + +Composable frontends provide significant advantages - you can be quite flexible in replacing backend components without disruption, are free from vendor lock-in through multi-backend integration, and are able to adapt to changing requirements with the ability to scale specific parts based on business demands. + +### The separation of concerns + +In building Open Self Service, we chose to implement a clear separation of concerns between different layers of the application. While there are multiple ways to achieve composable architecture, our approach focuses on: + +- complete **separation of the presentation layer from the data and business logic layers**, which allows each to evolve independently and enables the frontend to work seamlessly with multiple backend services + +- introduction of an intermediate **API composition layer** that acts as a bridge between the frontend and various backend APIs. This layer aggregates data from multiple sources and orchestrates data flows between systems. It efficiently combines static content with dynamic data while handling complex logic server-side, reducing browser processing overhead + +![high level architecture](./high-level-architecture.svg) + +This kind of approach ensures backend service changes don't require frontend code modifications (as long as that backend API is still backwards compatible), reducing maintenance overhead and increasing the overall flexibility. + +## Performance strategies + +Now that we understand the architectural foundation, let's explore the specific performance strategies that make composable applications fast and responsive. These techniques leverage the modular nature of our [blocks system](https://www.openselfservice.com/docs/main-components/blocks/) to deliver optimal user experiences. + +### Leveraging server components + +Probably one of the easiest "wins" is to take full advantage of [Next.js server components](https://nextjs.org/docs/app/getting-started/server-and-client-components) to perform data fetching and initial rendering on the server. Each block in our system follows a clear separation between server and client components: + +```typescript +export const OrderDetailsServer = async ({ id, orderId }) => { + // Fetch block data from API composition layer + const data = await sdk.blocks.getOrderDetails({ id: orderId }); + + // Pass data to the client component + return ; +}; +``` + +```typescript +'use client'; + +export const OrderDetailsClient = (props) => { + // Render the actual component + return (
...
); +}; +``` + +This pattern ensures that data fetching occurs on the server, reducing client-side JavaScript bundle size and eliminating client-server waterfalls. The server component fetches the necessary data and passes it to a client component that handles interactivity. + +### Streaming with Suspense + +By using server components, we can also easily implement component-level streaming using React's Suspense, allowing parts of the page to load progressively rather than waiting for all data to be available. This approach ensures that slow-loading blocks (e.g. due to a slow or complex backend API calls) don't block the rendering of faster ones, and users can start interacting with parts of the page while others are still loading. + +Strategic placement of Suspense boundaries is crucial for optimal streaming performance. In our implementation, we place these boundaries at the block level rather than at the page level, allowing for more granular control over the loading experience: + +- each block has its own Suspense boundary, allowing it to stream independently +- more complex blocks can prepare the loading state to more or less represent how the component may actually look when it's ready + +Let's look at the `OrderDetails` block that is responsible for showing the users the details of one of their orders. It consists of a title, some tiles arranged in a grid, and a list of products that were purchased. + +![order details block.png](order-details-block.png) + +For optimal user experience - and to minimize layout shift that could occur during the page load - we prepare a similar layout in a "renderer" component that wraps the server component, using [shadcn/ui's Skeleton components](https://ui.shadcn.com/docs/components/skeleton): + +```typescript +export const OrderDetailsRenderer: React.FC = ({ id }) => { + return ( + + +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+ + + }> + // The component itself that is rendered when it's fully ready + +
+ ); +}; +``` + +When users enter this page, they will be able to: + +- see meaningful loading states while data is being fetched +- partly interact with the app (e.g. use the main navigation) even before the main content is fully ready + +And once the initial HTML is prepared server-side, it is immediately streamed to the browser, which can be seen on the following (artificially slowed-down so that loading states are actually visible) video showing a single block: + +![single block loading progress](./single-block-loading.gif) + +### Parallel data loading + +By using server components, we can enable parallel data loading, where multiple blocks on a page can fetch their data simultaneously rather than sequentially. Each block is responsible for its own data fetching, and the Suspense boundaries around each block allow them to load independently: + +```typescript +export default function KnowledgeBasePage() { + return ( +
+ + + +
+ ); +} +``` + +This effect is more interesting on pages that include more than one component, where each can take a different time to load (due to more complex backend logic, database access, network delays, or other causes). + +Let's take a look at [another example](https://demo-dxp.openselfservice.com/en/personal/help-and-support), this time a digital experience portal with a knowledge base area. Once more, each component is streamed to the browser as soon as it's ready and is then available to interact with: + +![multi block loading progress](./multi-block-loading-1.gif) + +While the fallback components are not sized to perfectly match their full counterparts, they don't have to be. It's enough for them to be "close enough", just so that they can be visually similar to the final rendered components, especially considering that some content will be very dynamic when it e.g. depends on a CMS configuration. + +As already mentioned, using Suspense has the additional benefit of reducing [Cumulative Layout Shift](https://web.dev/articles/cls). Let's see what this process would look like if only one block did not provide any fallback states and notice how the article tiles "jump down" when the tiles above them appear: + +![multi block loading progress](./multi-block-loading-2.gif) + +While this is a mostly harmless example, just take a look at another, more dangerous example from [web.dev](https://web.dev/articles/cls): + +![cumulative layout shift.gif](cumulative-layout-shift.gif) + +It's obvious that providing appropriate placeholders is critical not only to increase the overall performance (a high CLS will lower your Lighthouse score) but also to safeguard against potentially harmful actions. + +So to sum up - this pattern eliminates the "waterfall" effect where one component must finish loading before the next one begins, significantly reducing the overall page load time. As a side effect, it can also provide a better user experience. Instead of using a single loader for the whole page or even delaying the page rendering until every single piece of data is fetched and ready for rendering, you can actually show the page loading progress. + +### Component-level dynamic imports + +Beyond block-level code splitting, we use dynamic imports for heavy components within blocks. This is particularly beneficial, e.g., for data visualization components that rely on large third-party libraries: + +```typescript +'use client'; + +import dynamic from 'next/dynamic'; + +// Dynamically import the chart component to reduce initial bundle size +const StackedBarChart = dynamic( + () => import('@o2s/ui/components/Chart/StackedBarChart').then((module) => module.StackedBarChart), +); + +export const PaymentsHistoryClient = ({ title, chartData }) => { + return ( +
+ {title} + ... + +
+ ); +}; +``` + +In this example, the chart component (which depends on the [recharts library](https://recharts.org/)) is dynamically imported only when needed (in other words, when that block is rendered on the frontend). Chart libraries are typically large and would significantly increase the initial bundle size, and typically not every page in the app will contain a chart component - so there's no point in preloading a resource-heavy library before it's actually necessary. + +### API composition layer + +The API composition layer serves as a critical intermediary between frontend blocks and backend services. It can be implemented in a variety of different ways, but we chose to use [Nest.js](https://nestjs.com/) as a framework which fits in nicely in the whole TypeScript-based tech stack. + +As an example for what we can achieve using such architecture, let's look at the service that is responsible for the endpoint for fetching data for the `OrderDetails` block: + +```typescript +// Data aggregation from multiple sources +export class OrderDetailsService { + constructor( + // Service for fetching static content e.g. from some CMS + private readonly cmsService: CMS.Service, + // Service that returns dynamic data for the user from some backend API + private readonly orderService: Orders.Service, + ) {} + + getOrderDetailsBlock(params, query, headers) { + const cms = this.cmsService.getOrderDetailsBlock({ ...query, locale: headers['x-locale'] }); + const order = this.orderService.getOrder({ id: params.id }); + + // Fetch data from both sources simultaneously + return forkJoin([cms, order]).pipe( + map((order) => { + // Transform and combine data for the frontend app + return mapOrderDetails(cms, order); + }), + ); + } +} +``` + +This approach provides several benefits for the overall performance. Firstly, the composition layer combines data from multiple backend services into a single, optimized response that is specifically tailored for each block - returning only the information that block needs and nothing else, which eliminates overfetching. + +Instead of making multiple API calls directly from the browser (e.g. for user actions like filtering or form submissions), the composition layer handles the communication with backend services, which reduces latency and bandwidth usage. The composition layer can fetch data from multiple sources in parallel using, for example, [RxJS observables](https://rxjs.dev/), optimizing the overall response time + +Additionally, raw data from various backend services is transformed into a consistent format, which does not necessarily improve performance itself but allows the frontend to be implemented in an API-agnostic way. + +### API-level caching + +However, in a composable architecture where each block independently fetches its own data, there's a risk of redundant API calls to the same backend services. Therefore, it's important to also provide a caching mechanism that can help to reduce the load on backend services. + +We chose to address this issue by leveraging [Redis](https://redis.io/) that can e.g. cache static CMS responses for use in other blocks. While, of course, some static content is still very block-dependant, other is shared - like general config or reusable generic translations - and can be safely cached. + +In our framework, blocks don't need to implement caching logic themselves; they benefit automatically from the centralized caching system. When multiple blocks on a page require the same underlying data, the first request populates the cache, and subsequent requests are served from the cache without hitting the backend services. + +### Request memoization + +Next.js 13 and later versions introduced an important performance optimization feature: automatic request memoization. It ensures that identical data fetching requests within the same render pass are actually called only once, significantly reducing unnecessary network calls and improving performance. + +This is especially important for composable apps where the frontend does not define every page, instead relying on a CMS configuration. In such cases, there will be, for example, two identical requests needed for a single page render: one to [generate metadata](https://nextjs.org/docs/app/getting-started/metadata-and-og-images#generated-metadata) and the second to actually render page body: + +```typescript jsx +export async function generateMetadata({ params }: Props): Promise { + const { locale, slug } = await params; + const { data, meta } = await sdk.modules.getPage({ slug }, locale); + + return generateSeo({ data, meta }); +} + +export default async function Page({ params }: Props) { + const { locale, slug } = await params; + const { data } = await sdk.modules.getPage({ slug }, locale); + + return ( + +
+ +