From Zero to Hero: Deploying a Full-Stack monorepo with Web, API, and IaC in Just 5 Minutes
How to quickly rollout a basic 3 layer application repository for quick prototyping in NodeJS with shared code across all layers. From repository creation to being Internet accessible in minimum time.
In this post I will share with you the steps I go through for every new project, be it a quick experimentation or professional greenfield project to quickly rollout a basic skeleton for a web front-end, API back-end and AWS CDK (in this example, could also be Azure ARM, Terraform, Pulumi) IaC (Infrastructure-as-Code). The same applies for any permutation of the above with any number of services.
While it is possible to create a shared code monorepo manually, it would not be complete in 5 minutes and would entail undifferentiated heavy lifting on our part. To avoid such inneficiencies, we will use Nx for this (could also use Yarn workspaces if scope is limited and small) as it provides plentiful of plugins for most of the frequently used toolings in the wild (you can use this as a measuring stick for the popularity of some tools, I have discovered some new ones browsing the Nx plugin list).
This is the same process I started with when creating my CV, as detailed here and here, so as you can see, this is another powerful tool in your belt, that can be used for the simplest to the most complex setups.
The Nx CLI is a robust tool that offers a wide range of options and follows specific best practices. To guarantee that our setup remains consistent over time—whether the commands are executed today, next week, or a year from now—we will specify a few parameters. This ensures our project configuration remains uniform, regardless of when the commands are run.
This post is divided into two primary sections:
Project bootstrapping
This involves setting up the initial structure for web, API, and Infrastructure-as-Code (IaC) applications, along with a shared library that these applications will use.
Coding
This section covers adding minimal code to enhance the modules with essential functionality.
In order to better visualize the final relation between the modules, I have ran
npx nx graph
to view in the browser the dependency relationships between the projects, which looks as follows:
Note
The diagram mentioned earlier represents a compile-time dependency graph, created using the graph command. This graph shows the dependencies that are determined during the compilation process. If we were to create a graph illustrating runtime dependencies, it would include an additional arrow pointing from the web to the API. This arrow signifies that the ReactJS front end makes calls to the API to fetch data. This data fetching is facilitated by code that is compiled into the application's binary, using functions imported from the shared library. The concept and its implementation will be more comprehensible when we delve into the code in section 2.
The architectural view of the deployed solution will look as follows:
1. Project bootstrapping
This section is mainly in the CLI so the feedback loop will be fast.
1.1. Create Nx Workspace
The documentation of the command can be found here.
npx create-nx-workspace@latest --preset empty --nxCloud skip --packageManager yarn --workspaceType integrated engineeringmindscape
where the parameters are as follows:
--preset none
Creates an empty monorepo project with no dependencies. We will manually populate only what we need in the next steps.
--nxCloud skip
No need for cloud build caching for this demo app but it is nice to have otherwise.
--packageManager yarn
Personal prefference to use yarn but npm, pnpm are also available.
--workspaceType integrated
As I want to highlight the power of the integrated monorepo, we need to ensure we can refer to libs cleanly :).
engineeringmindscape
This will be the name of the overall monorepo project and the namespace that will be used for the libraries.
The output of the above command looks as follows:
Now we can enter the newly created monorepo directory:
cd engineeringmindscape
The file structure of the generated base monorepo looks as follows:
1.2. Install Nx plugins
Now we can add as dev dependency the Nx plugins for NodeJS and AWS CDK and the needed CDK construct plugin.
yarn add -D @nx/react @nx/node @nx-iac/aws-cdk @aws-solutions-constructs/aws-apigateway-lambda
This is the plugin that we will use to generate the web application. It has several generators such as hooks, stories, redux, components, etc but we will only be using the application one for now.
We will be using this for the API backend. It also has several generators from which we will also be using the application generator.
This is a custom plugin that will bootstrap our AWS CDK IaC application from which we will need the app generator.
@aws-solutions-constructs/aws-apigateway-lambda
AWS provided L3 construct for CDK to generate all the resources required for a API Gateway fronted Lambda with minimal code.
1.3. Create the ReactJS Web App
Let us begin bootstrapping the first application of our stack, the ReactJS website with the Vite bundler:
npx nx g @nx/react:application --name web --directory apps/web --bundler vite --style css --routing false --e2eTestRunner playwright --minimal true --projectNameAndRootFormat as-provided
--name web
The name of the ReactJS web application.
--bundler vite
The bundler to use for the ReactJS project, vite or webpack is available.
--style css
The stylesheet system to use for the web app, we are keeping it lightweight as possible and not including SASS/LESS/etc.
—routing false
For this basic scenario I do not want to have routing configured as it will be literally a single page SPA(Single Page Application)
--e2eTestRunner playwright
Each web project gets a side E2E testing project, with options for cypress and playwright. We are going with playwright.
—minimal true
No need for separate test files, let’s keep it light.
--projectNameAndRootFormat as-provided
To keep the structure specifically as I have provided in the directory parameter.
After running the above command, the following output and files have been created:
1.4. Create the NodeJS API App
We can now leverage the previously installed @nx/node
plugin to generate our API application:
npx nx g @nx/node:application --name api --bundler esbuild --framework none --projectNameAndRootFormat as-provided --directory apps/api
--name api
Name of the application and folder where it will be placed in.
--bundler esbuild
The bundler to use for the NodeJS project, esbuild or webpack is available.
--framework none
As we will have a basic Request-Response Lambda, we will simply return a canned response with no need for Express/Fastify/Koa.
--directory apps/api
We specify concretely the folder to place the new application in.
--projectNameAndRootFormat as-provided
To keep the structure specifically as I have provided in the above parameter.
The documentation for the command can be found here. After running the above command, this is what we get in the terminal:
1.4. Create the shared NodeJS library
In order to fully showcase the power of this monorepo setup, we will create a library that will contain shared definitions, such as the name of the project, that will be used across all of the applications in a visible manner to the end user. For now we will simply bootstrap the library as follows leveraging the same @nx/node
plugin:
npx nx g @nx/node:library --name shared --directory libs/shared --projectNameAndRootFormat as-provided --testEnvironment node
--name shared
The name of the library
--directory libs/shared
The directory where to place the new library specifically.
--projectNameAndRootFormat as-provided
As before, this tells Nx that I want to specifically have the folder structure I provided, to ensure consistency throughout time.
--testEnvironment node
The environment for the testing aspect of the library, node or jsdom are available options.
The documentation for generating the library command can be found here. After running the above command, we can see the following in the CLI:
1.5. Create the AWS CDK IaC App
As the final bootstrap step, we generate a base AWS CDK application using the second
installed plugin @nx-iac/aws-cdk:
npx nx g @nx-iac/aws-cdk:app --name iac --directory apps/iac --projectNameAndRootFormat as-provided
--name iac
The name of the application
--directory apps/iac
The directory where we are going to place the AWS CDK code.
--projectNameAndRootFormat as-provided
We ensure the layout will be as specified in the above directory parameter.
The documentation for this custom Nx plugin can be found here. The output of running the above command is as follows:
1.6. Full monorepo skeleton
Now after running the above bootstrap commands for the web, api and IaC apps as well as for the shared library, with the 18.0.2 version of Nx we get the following files:
2. Coding
Starting from the foundational elements and moving upward, we will enhance the initially bootstrapped files with necessary modifications to showcase the power and speed of our setup.
2.1. Shared Library
In shared/src/lib/shared.ts
we add the following:
export const PROJECT_NAME = "EngineeringMindscape";
2.2. API App
In apps/api/src/main.ts
already we can start leveraging the shared code above:
// this is the imported symbol from the shared library
import { PROJECT_NAME } from '@engineeringmindscape/shared';
export const handler = (event: unknown, context: unknown, callback: (err: null, resp: Record<string, number|string|Record<string, string>>) => void) => {
const response = {
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "*",
},
body: JSON.stringify({
api: PROJECT_NAME,
}),
};
callback(null, response);
};
We also need to modify in apps/api/project.json
“bundle”: true
as this will ensure the entire code is bundled in a single output file to be used by Lambda.
2.3. Web App
In apps/web/src/app/app.tsx
now we also leverage the shared library directly as well as call the API to receive a JSON payload that will contain the shared content, both of which we will display on the page:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import styles from './app.module.css';
// this is the imported symbol from the shared library
import { PROJECT_NAME } from '@engineeringmindscape/shared';
import { useEffect, useState } from 'react';
export function App() {
const [data, setData] = useState(null);
useEffect(() => {
// this is the environment variable from the .env file,
// which we will populate with the API URL after first deployment
// as that is when we will know the URL of the API
fetch(`${import.meta.env.VITE_API_URL}`)
.then(response => response.json())
.then(data => setData(data));
}, []);
return (
<div>
{PROJECT_NAME}
{data && <div>{JSON.stringify(data)}</div>}
</div>
);
}
export default App;
Note
The VITE_API_URL
environment variable will be populated with the API's URL after its initial deployment, as that's when the API Gateway's URL becomes known. Ideally, to manage dependencies between stacks, the web application's build and deployment would follow the API's creation. However, prioritizing speed and simplicity, we'll perform the CDK deployment process twice, which will be detailed in section 2.5.
2.4 IaC App
In apps/iac/cdk/IacApp.ts
we update the code of the entrypoint for the CDK app as follows:
import * as cdk from 'aws-cdk-lib';
import { SampleStack } from './stacks/SampleStack';
// this is the imported symbol from the shared library
import {PROJECT_NAME} from '@engineeringmindscape/shared';
const app = new cdk.App();
new SampleStack(app, PROJECT_NAME, {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
As well we need to edit the apps/iac/cdk/stacks/SampleStack.ts
to actually deploy the appropriate API Gateway, Lambda, Roles, CloudWatch Log Groups, S3 Bucket and bucket deployment Lambda:
// this is the imported symbol from the shared library
import {PROJECT_NAME} from '@engineeringmindscape/shared';
import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { ApiGatewayToLambda } from '@aws-solutions-constructs/aws-apigateway-lambda';
import { Construct } from 'constructs';
import * as api from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
export class SampleStack extends Stack {
constructor(construct: Construct, id: string, props?: StackProps) {
super(construct, id, props);
// AWS provided L3 constructs to deploy API Gateway -> Lambda configuration
new ApiGatewayToLambda(this, `${PROJECT_NAME}-API-GW-Lambda`, {
lambdaFunctionProps: {
functionName: `${PROJECT_NAME}-API-GW-Lambda`,
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'main.handler',
code: lambda.Code.fromAsset(`${__dirname}/../../../../dist/apps/api`),
},
apiGatewayProps: {
restApiName: `${PROJECT_NAME}-API-GW`,
defaultCorsPreflightOptions: {
allowOrigins: api.Cors.ALL_ORIGINS,
allowMethods: api.Cors.ALL_METHODS
},
defaultMethodOptions: {
authorizationType: 'NONE'
}
},
});
const s3Bucket = new s3.Bucket(this, `${PROJECT_NAME}-S3-Bucket`, {
removalPolicy: RemovalPolicy.DESTROY,
bucketName: `${PROJECT_NAME.toLowerCase()}-monorepo-demo`,
publicReadAccess: true,
autoDeleteObjects: true,
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'index.html',
// required due to https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/
objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
blockPublicAccess: new s3.BlockPublicAccess({
blockPublicAcls: false,
ignorePublicAcls: false,
blockPublicPolicy: false,
restrictPublicBuckets: false,
}),
});
// deploy web app to s3 bucket
new BucketDeployment(this, `${PROJECT_NAME}-S3-Deployment`, {
sources: [Source.asset(`${__dirname}/../../../../dist/apps/web`)],
destinationBucket: s3Bucket,
metadata: { ForceRedeployment: Date.now().toString() }
});
// output the S3 bucket URL
new CfnOutput(this, `${PROJECT_NAME}-S3-Url-Output`, {
value: s3Bucket.bucketWebsiteUrl,
});
}
}
The above CDK stack while not production ready is very short and readable achieving the swiftness and conciseness criteria which are of utmost importance for this current endeavour.
2.5. Building and Deploying
Now that we have all the code in place, all that is left is to build and deploy:
nx build api
nx build web
Now to deploy it all (I assume you have an AWS account that is bootstrapped already, otherwise you also need to run the @nx-iac/aws-cdk:bootstrap command):
nx deploy iac
After deploying the whole stack, terminal will output something similar to
Now as mentioned during the web app editing done in section 2.4, now that we have the Outputs
from the stack, we can use the APIGWLambdaRestApiEndpoint
value as follows:
echo “<APIGWLambdaRestApiEndpoint value>“ > .env
nx build web
nx deploy iac
Now we can finally access the second Outputs
value of S3UrlOutput
to view our final result:
When accessing the S3 website URL we are greeted with a plain page with the first row being the shared library PROJECT_NAME
imported into the web project and build within the distributable.
The second row is the API Gateway Lambda response which also contains within the build distributable the shared library value, which we fetched and are displaying directly in the page.
Finally, the AWS CDK CloudFormation stack itself uses the value in the name of the Lambda, Stack and even in the S3 bucket name, which is visible client side as well by looking at the URL in the browser above.
Conclusion
In conclusion, the journey from initializing a monorepo with Nx to deploying a fully functional web application stack on AWS demonstrates the power and efficiency of modern development tools and practices. By leveraging Nx for project scaffolding, we benefit from a streamlined development process that integrates seamlessly with various technologies, including React for the front end, Node.js for the API backend, and AWS CDK for infrastructure-as-code management. This approach not only accelerates the setup phase but also ensures consistency and scalability across the entire project lifecycle.
The shared library concept further exemplifies the advantages of a monorepo setup, facilitating code reuse and maintaining consistency across different parts of the application. By centralizing shared definitions and functionalities, developers can ensure that changes in one area are automatically propagated throughout the project, reducing the risk of discrepancies and bugs.
Deploying the application stack using AWS CDK showcases the practical benefits of infrastructure-as-code, allowing for reproducible and predictable infrastructure provisioning. This method simplifies the deployment process, making it easier to manage and scale cloud resources efficiently. The integration of AWS solutions constructs further streamlines the creation of cloud resources, enabling developers to focus on building the application logic rather than managing infrastructure.
This post has outlined a comprehensive yet straightforward approach to kickstarting a web application project, from setup to deployment. By embracing these modern development practices and tools, developers can significantly reduce the time and effort required to bring their projects to life. Whether you're working on a pet project or a professional greenfield project, the methodology described here provides a solid foundation for developing robust, scalable, and maintainable web applications.
As always, the entire repository code for this solution can be found here.