Scaling smallcase managers’ microsite with Next.js
The smallcase ecosystem has a lot of SEBI-registered investment advisers (RIAs), research analysts (RAs) and Portfolio Manager (PMS) who use Publisher Platform to create portfolios as smallcases & share them with their clients seamlessly. One such mode of sharing is via their “microsite”.
On micro-site, clients can view smallcases with all historical performance, CAGR, daily change values, related news, can subscribe & invest in smallcases with their existing broker & demat account in a click
In this article, we will go through the evolution of these microsites ( for example weekend investing, capitalmind, wright research …) over the years from 2019 to date. And see how it led us to adopt an SSR approach using Next.js. This is the story of how we adapted non-SSR friendly code into a multi-tenanted service that scales to almost every microsite.
Structure of Microsite
Before going into this journey, first let us get a basic understanding of microsite structure.
Microsite, is a no login website. It is always hosted on .smallcase.com
subdomain. It consists of three pages in general:
- Landing Page
/
- smallcase Profile Page
/smallcase/:scid
- Help Page
/help
The Landing and Help page consists of managers brand information. Manager’s detail helps users learn more about the people behind the research & the research philosophy. The FAQ section of the microsite helps users learn more about their services, communication details etc. These websites share the same layout, core features and most of the components. There is some degree of flexibility allowed. These are controlled by so-called feature/fold flags. Pages or components on each route can also be configured.
Microsite v0.x.x
When the Publisher product was launched, we had very few smallcase Managers. During this time frame, the microsite was a SPA react application. It was using an Adapter pattern ( as shown below) to access all managers information that was stored inside the repo itself. This approach worked due to the small number of managers available ( 7 – 12 ). To this day broker’s smallcase platform follow the same pattern with a different workflow.
/**
* This gets the smallcase manager configuration from adapter
* folder. PUBLISHER (aka manager's id) is passed using
* DefinePlugin of webpack during compile process.
*/
const publisherConfig = require(`./adapters/${PUBLISHER}`);
During deployment of these microsites, adapters were used to build different bundles for each smallcase managers microsite and later, we synced assets to the respective S3 bucket of these managers provided by smallcase.
But soon enough we reached a point where it was getting difficult to manage information using adapter pattern, due to the following reason:
- Constant update to manager’s information was increasing.
- It was also hampering developer experience due to constant communication overhead and adhoc deployments.
This led to next version of microsite.
Microsite v1.x.x
This newer version of the microsite led us to the creation of an in-house CMS solution, which is now the part of the internal dashboard at smallcase called “smallboard”. As the majority of smallcase managers content are serializable; this lead to the site’s content decoupled from the microsite codebase. Recently (2021) we also launched a feature where managers can manage their website including their information, disclosures, agreement and billing details directly from the Publisher Platform.
Using this, Managers, or anyone from the business team or relationship team at smallcase can update smallcase managers information and deploy the latest changes to respective microsites without needing help from developers. This was achieved using a very unique way of calling API before the webpack bundling process as described below. We adopted this process so that managers’ specific configuration is fetched during compile time. This stopped us from introducing another API call during runtime which would have hampered web vitals score. This approach worked for almost a year (2020).
// webpack.config.js for version v1.x.x
...
module.exports = async env => {
...
const isMaintenance = env.NODE === 'maintain';
if (!isMaintenance) {
try {
if (!env.STATS)
console.log(`Building for smallcase manager: ${env.PUBLISHER}`);
const resp = await axios.get(
`${
envConstant[env.NODE].API_URL
}/microsite/build?publisher=${env.PUBLISHER}`,
{
headers: {
Authorization: process.env.MICROSITE_TOKEN
}
}
);
// smallcase manager's info becomes part of source code
// This config is used to render various folds in microsite.
fs.writeFileSync(
'src/config.js',
`export default ${JSON.stringify(resp.data.data, null, 2)}`
);
} catch (err) {
console.log(err);
throw 'Error while calling api/writing config';
}
}
// standard webpack config for SPA React Application.
return {
....
};
};
As the ecosystem and product grew, we were starting to face issues with this solution also. The number of managers is increasing rapidly, which will also increase the number of microsites being maintained and updated by the Publisher Team. To understand this problem, we need to first understand the deployment process of the Microsite.
Deployment Process of Microsite
The deployment process can be triggered from 3 places:
- smallboard – It is used by internal/business team at smallcase
- Publisher Platform – On the profile tab of the publisher platform, managers now have options to update the microsite details on their own (updated after 24hr using a scheduled job).
- Maintainer – A script/jenkin’s pipeline is used to deploy microsite whenever a new feature is added to it.
In short a single deployment of managers microsite use to takes following steps:
- Jenkin pipeline is started
- Microsite repo is fetched from remote location
- Do a npm install
- Fetch managers information and save it to
src/config.js
- Webpack build is started
- Once the assets are generated, Source map is uploaded to sentry
- Build Assets are synced to respective manager’s microsite
- Cloudfront CDN is invalidated after that
On average this process took 2-3min to complete. And it was repeated for all microsite deployment.
Issues with Microsite v1
- Atomic deployment is not possible. It takes a lot of time to deploy all microsites ( For 90+ microsites, it takes more than 2 hrs to ensure all microsites are working fine.
- Various microsites may be running on different microsite versions and only the latest version is maintained. If any major publisher or smallcase API change happens, it might potentially break older microsites.
- Multiple deployments are a problem, as it takes a lot of time to deploy even a small change. It is much more painful, if such changes are frequent or urgent. One reason for this is that all of the deployments need to access our private self hosted npm registry, and it can’t handle so many parallel accesses, so we end of batching and queueing the deployments.
- Infra resources were wasted for multiple builds and deployments
- Since we have different deployments, different microsites may be on a different version of the codebase, which becomes difficult to handle in case a new feature is required by a microsite which was till now on a very old version.
- Putting microsites in maintenance mode or refreshing configs requires redeploying which requires a lot of time.
With all these issues faced in this version of microsite due to scale at which smallcase is growing as an ecosystem; It led us to a solution to re-architect the codebase of microsite so that only a single deployment takes care of all the microsite and separation is maintained on application level rather than infrastructure level.
Microsite v2.x.x
In smallcase, Next.js is being used for a lot of greenfield applications, so it was an obvious option when it came to choosing which framework to move to. Around March-end 2021, we launched the new Multi-tenant SSR version of microsite which supported rendering of pages on the fly and caching pages on CDN ( AWS CloudFront ). Its has not only improved our deployment process but also improved the overall web vital scores of these sites by a huge margin.
Migration to Next.js
Let’s look in more detail at the architecture. When a user tries to visit a certain microsite. The browser request goes to AWS’s CDN. If the pages are cached on the nearest edge node, then the CDN will respond with the cached pages. If not, the request is then forwarded to the origin server. All of the microsite’s CloudFront distribution is connected to a load balancer. The Cloudfronts are configured to forward the hostname to the load balancer. The load balancer then forwards the request to a server running in ASG on a certain port. The Next.js application is run using pm2 in cluster mode on the server. Cluster mode will prevent downtime by allowing application to be scaled across multiple CPUs.
With the help of hostname from request, the Next.js server renders the page and forward it to the CloudFront along with certain cache-control headers, so that it can be cached on the edge node of CDN. After migration to Next.js, it also increased the CDN hit ratio and lower the percentage of GET requests that didn’t finish downloading over time.
Today, when Managers update information for their microsite via the Publisher platform. Only a single CloudFront invalidation is required to make those changes reflect on their live website. On the contrary, whenever a new feature is developed for a microsite or a bug fix happens, we just push our changes to Production Next.js server. And then, later on, invalidate all CloudFront distribution ( in an automated way ) for those changes to reflect on all microsites. The total deployment time of all microsites has been reduced by almost 97%. Currently, the deployment takes merely 2-3 mins for all microsites. After migration to Next.js, it also increased the CDN hit ratio and lowered the percentage of GET requests that didn’t finish downloading over time.
For now, we are only rendering the Landing and Help page on the server which contains static information of managers. In future, we will also render an SSR version of these smallcase profiles on microsites too.
Below code snippets takes care of how microsite information is fetched on server.
// getPlatformPrefix (production.ts)
export function getPlatformPrefix(appContext: AppContext) {
let host;
if (appContext.ctx.req) {
host = appContext.ctx.req.headers.host;
} else {
host = window.location.hostname;
}
return host?.split('.')[0];
}
// Production getPublisherInfo.ts
import { AppContext } from 'next/app';
import apiMap from '~/constants/apiMap';
import request from '~/lib/request';
import { getPlatformPrefix } from '~/utils/publisher';
async function getPublisherInfo(appContext: AppContext) {
const identifier = getPlatformPrefix(appContext);
const response = await request({
query: apiMap.MICROSITE_INFO_FROM_PLATFORM_PREFIX,
searchParams: { platformPrefix: identifier },
customHeaders: {
Authorization: MICROSITE_TOKEN,
},
});
return response.data?.data ?? {};
}
export default getPublisherInfo;
// _app.tsx
class Microsite extends NextApp<AppCustomProps, AppInitialProps> {
static async getInitialProps(appContext: AppContext) {
// getServerSideProps is not supported in _app.tsx ( v11 )
let unavailable = false;
if (process.browser ? !micrositeMetaData : true) {
try {
const response = await getPublisherInfo(appContext);
micrositeMetaData = processMicrositeData(response);
// adding publisher to response header for APM
appContext?.ctx?.res?.setHeader?.(
'x-sc-publisher',
micrositeMetaData.name,
);
} catch (err) {
console.log(err);
logger(loggerContants.MICROSITE_META_FETCH_FAIL, err, {
tags: {
server: true,
},
});
micrositeMetaData = undefined;
unavailable = true;
}
}
return {
pageProps: {},
query: appContext.ctx.query,
micrositeMetaData,
unavailable,
};
}
render() {
const { Component, pageProps } = this.props;
if (process.browser && !micrositeMetaData) {
micrositeMetaData = this.props.micrositeMetaData;
}
return (
<AppWrapper
micrositeMetaData={micrositeMetaData}
unavailable={this.props.unavailable}
>
<Component {...pageProps} />
</AppWrapper>
);
}
}
export default Microsite;
Dev and Staging Environment
The above logic ( of finding which managers microsite to render ) is different on dev and staging environment as we don’t want to host so many websites on non-production env. Here the identifier is sent to the non-production Next.js server via a cookie. This approach not only helps developers to test things in the dev environment but also the QA team on staging.
To change cookie, a UI component is used so that its much easier to change to switch between website. Using conditions on dynamic component in Next.js, this UI component never becomes part of final production assets due to obvious reasons. Below code snippet decides when to use this cookie logic.
// nonProduction.ts
import { AppContext } from 'next/app';
import apiMap from '~/constants/apiMap';
import request from '~/lib/request';
import { getCookieFromReq } from '~/utils/publisher';
async function getPublisherInfo(appContext: AppContext) {
const identifier =
getCookieFromReq(appContext.ctx.req, 'publisher_name') ??
'smallcaseHQ';
const response = await request({
query: apiMap.MICROSITE_INFO_FROM_NAME,
searchParams: { publisher: identifier },
customHeaders: {
Authorization: MICROSITE_TOKEN,
},
});
return response.data?.data;
}
export default getPublisherInfo;
// getPublisherInfo.ts
const getPublisherInfo = require(`./${
!(LOCAL === 'true' || ENV === 'development' || ENV === 'staging')
? 'production'
: 'nonProduction'
}`).default;
export default getPublisherInfo;
Onboarding a New Manager
Whenever a new manager joins the smallcase ecosystem and is eligible to have a microsite, they go through a DIY onboarding process on the Publisher platform. During this process, we ask for a subdomain that they would prefer along with other necessary information. Once this information is shared, managers undergo a verification process. After the process is complete, we set up necessary new configurations to our infrastructure such as a new entry to route53, and the creation of new CloudFront distribution that enables access to the new microsite. The below code snippet show how we have automated the process.
/**
* It creates necessary infra for new manager's microsite
* @param {string} publisher - smallcase manager id
* @param {string} subdomain - subdomain on smallcase.com
*/
async function createSSRInfrastructure(publisher, subdomain) {
const cf = new aws.CloudFront({ apiVersion: '2020-05-31' });
const r53 = new aws.Route53({ apiVersion: '2013-04-01' });
const configPublisher = require('../../config/publisher')({
subdomain,
});
try {
/**
* A new Cloudfront distribution is added with origin pointing to
* an ALB that points to Next.js server.
*/
const cfCreateDistributiondata = await cf
.createDistribution(configPublisher.CFParamsPublisher)
.promise();
// Configure route53
configPublisher.r53Params.ChangeBatch.Changes[0].ResourceRecordSet.AliasTarget.DNSName =
cfCreateDistributiondata.DomainName;
// Create A record for Above given domain name.
await r53.changeResourceRecordSets(configPublisher.r53Params).promise();
return cfCreateDistributiondata.Distribution.Id;
} catch (error) {
await changeMicrositeStatus(publisher, 'INACTIVE');
console.log(`Error in creatting infrastructure : ${error}`);
throw error;
}
};
Currently, we can support managers adding their domain in some instances. For example, IIFL Securities have a microsite hosted on its domain. Here, we are adding alternate Domain Names (CNAMEs) by adding IIFL securities SSL certificate on a specific CloundFront distribution managed by smallcase and ensuring that its subdomain matches the platform prefix of manager stored in our database. However, if we were to start to support a microsite on a custom domain (with no subdomain) soon, we will have to make necessary changes on how Next.js uses the hostname to resolve a manager’s microsite. ( Here, instead of a subdomain, the full hostname can be used ). Another way is that manager can map their custom domain to the subdomain provided by the smallcase domain by adding the CNAME DNS record at their end (custom SSL certificate management is also required here). However, this process will be cumbersome.
Metrics of Microsite
Currently our Next.js custom server is managing more than 250+ microsites. Following are the benefits that we have seen first hand.
As soon as we migrated to Next.js, it benefited us a lot.
- Improved SEO ( now creating dynamically generated sitemaps and robot.txt )
- Better Performance
- Improved Web vitals
- Analyse and measure the performance of pages via
reportWebVitals
- Out of the box Typescript support ( in v2.x.x, we migrated to Typescript, to better improve workflow )
- Improved developer velocity and productivity
Microsite APM using Prometheus
As we have our custom next.js server. We also wanted to monitor on its performance metrics. For this we use Prometheus to scrape metrics. And using grafana dashboard one can see those metrics real-time.
Exposing application metrics with Prometheus is easy, we just imported express-prom-bundle in our server’s middleware, which exposes /metrics route on the sever for Prometheus client to scrape data as shown in the below code snippet. Using this we were able to write our custom middleware for the custom next.js server.
// metrics.ts
import promBundle from 'express-prom-bundle';
import { IncomingMessage } from 'http';
import { ServerResponse } from 'node:http';
import { parse } from 'url';
/**
* It checks for the smallcase scid and adds it to prom labels.
*
* @param labels - Prom Labels
* @param pathname - request pathname
* @returns labels - Processed Prom Labels
*/
function checkForScProfile(labels: promBundle.Labels, pathname: string) {
if (/^/smallcase/.*/.test(pathname)) {
labels.scid = pathname.split?.('/')?.[2];
} else {
delete labels.scid;
}
return labels;
}
/**
* It checks for headers.
*
* @param labels - Prom Labels
* @param req - HTTP Request
* @param res - HTTP Response
* @returns labels - Processed Prom Labels
*/
function checkHeaders(
labels: promBundle.Labels,
req: IncomingMessage,
res: ServerResponse,
) {
if (req.headers?.host) {
labels.host = req.headers?.host ?? '';
} else {
delete labels.host;
}
if (res?._headers?.['x-sc-publisher'] as string) {
labels.publisher = res?._headers?.['x-sc-publisher'];
} else {
delete labels.publisher;
}
return labels;
}
const metricsMiddleware = promBundle({
normalizePath: [['^/_next/static/.*', '/_next/static/#assets']],
includeMethod: true,
includePath: true,
customLabels: {
host: undefined,
scid: undefined,
publisher: undefined,
},
transformLabels(labels, req: IncomingMessage, res: ServerResponse) {
labels = checkHeaders(labels, req, res);
// @ts-ignore
const parsedUrl = parse(req.url, true);
const { pathname } = parsedUrl;
labels = checkForScProfile(labels, pathname);
},
...
});
export default metricsMiddleware;
// server.ts
...
const envConfig = getEnvConfig({
env: process.env.APP_ENV ?? 'development',
local: process.env.LOCAL,
});
const server = express();
const PORT = parseInt(process.env.PORT, 10);
const app = next({
dev: envConfig.local,
customServer: true,
});
const handler = app.getRequestHandler();
// Measuring Metrics
server.use(metricsMiddleware);
...
app.prepare().then(() => {
...
server.all('*', (req: Request, res: Response) => {
return handler(req, res).catch((...args) => {
console.log(args);
return args;
});
});
server
// SENTRY - This handles errors if they are thrown before reaching the app
.use(Sentry.Handlers.errorHandler())
.listen(PORT, (err?: any) => {
if (err) throw err;
console.log(`Microsite running on ${PORT}`);
});
});
export default server;
Prometheus API also allows one to manage the alerts functionality. We have also placed Alert rules as effective monitoring and alerts will bring many benefits in terms of identifying instabilities and high-volume request spikes and greater agility in solving problems.
Open points
CloudFront CDN Growing Cost
Currently whenever a new manager is onboarded, we create a new cloudfront distribution for them and map it to their respective .smallcase.com
sub domain on route 53 respectively. There is an upper limit to the number of such distribution one can create on a single AWS account. We have to request Amazon to increase upper limit ( generally we just ask to set it 2x the current number). In coming year, the rate at which upper-limit is hit will increase. This will eventually sky rocket CDN cost once we reach a certain threshold. One way to solve this is to remove the managed CDN totally and build our own CDN and caching layer. There are few examples where people have done it. But for now we are sticking with a managed service due to obvious reasons.
Next.js out of the box solution
Next.js framework has evolved a lot over past years. They have provided out of box solution for similar problems if not the same. It allows one to compose multiple applications using Zones. Then in Next.js v10, internalisation routing was introduced. Using this feature one can sever content in much more personalized way to users. Currently on Next.js repo on github, a lot of discussion have been going around the best practices to solve multi-tennacy for frontend webapps.[Link 1, Link 2]. In one of such discussion, @leeerob, one of the maintainer of Next.js, informed that they are working towards providing a high-quality solution for multi-tenant applications. It would be pretty interesting to know how they will come up with a standard solution to solved variety issues that comes up with solving multi-tennacy.
Conclusion
At the end I would say, this was my first time implementing an SSR project on scale. I have a huge respect for Next.js framework, its tooling and its open source community. They have so many example to learn from and discussions that almost covers a lot of usescases. Choosing to use and getting familiar with Next.js has proven to be a great experience. We hope that sharing our experiences proves useful to other web developers, and we hope to continue sharing as we progress on our journey, solving problems and learning lessons along the way.
If you find this interesting or would like to join the mission to build better investment products for every Indian, let us know by applying to our job openings here!