Deploy-time configuration for a NextJS front-end bundle using CDK

A recent client project required me to deploy a single front-end bundle to multiple runtime environments on AWS. This is a pretty standard scenario: I created a reusable CDK stack to manage the environment instances and set up static asset deployment to S3 via the BucketDeployment construct. This worked out well, but one issue to resolve was that the bundle consumes a build-time config value which is different for each target environment (it’s a Shopify API app key).

The front-end bundle is built by Webpack as part of a NextJS project, so the initial trivial solution was to read the config value inside client-side code as a simple process.env variable with the NEXT_PUBLIC_ prefix. I stored the API key value in the project’s .env file before build time and that got inlined into resulting static assets by Webpack’s DefinePlugin:

// in client-side code
const shopifyAPIKey = process.env.NEXT_PUBLIC_SHOPIFY_API_KEY ?? '';

But with multiple target environment instances that meant changing .env and rebuilding the bundle for each deployed instance, which made the process brittle and tedious. Yes, in CI this is all automated anyway, but I’d still rather have a single build step.

I briefly considered just fetching the API key at runtime via dynamic request to the backend instead of reading process.env. After researching online, I could see that there was another obvious approach, which is to deploy a small config.json file with the rest of client-side static assets and fetch that at runtime. This takes advantage of browser cache and keeps backend code simpler.

// in client-side code
const { shopifyAPIKey } = await fetch('/config.json').then((response) =>
    ? response.json()
    : Promise.reject(new Error('cannot get config'))

How does this plug into the CDK-based deployment process?

CDK’s BucketDeployment accepts one or more Source objects for the uploaded file data. One source is of course the built client-side code bundle, for example the out folder generated by next export command in NextJS. We can add our config.json data to the deployment as a second source: a sort of “virtual file” defined by the Source.jsonData object:

// in CDK stack constructor
new s3deploy.BucketDeployment(this, 'DeployStaticAssets', {
  sources: [
    s3deploy.Source.jsonData('config.json', {
      shopifyAPIKey: '112233445566778899aabbccddeeff00'
  destinationBucket: staticAssetsBucket,
  distribution: mainDistribution, // trigger cache invalidation

What’s really useful about Source.jsonData is that it allows inserting deploy-time CDK values into the data: for example, the deployed hostname of a CloudFront distribution or any other output from a CDK construct. In my use case though, I just passed in the Shopify API key as a simple CDK stack prop, so I did not need to use this feature. Actually, at first I did attempt to read the key from AWS Secrets Manager (I used to stash it there for convenience even though Shopify app API keys are public) but that is not supported by Source.jsonData.

This left the local development environment incomplete – the config.json file does not exist before the CDK deployment step, but I still need to specify the dev API key somehow. I could have just created that file under /public and checked it into Git with the development key, but then there would be a chance that it leaked into production deploy, and of course this prevents each developer from having their own key or other additional local settings as needed. Instead I just kept using a NextJS environment variable, but explicitly renamed it to be development-only as NEXT_PUBLIC_DEV_SHOPIFY_KEY:

// in client-side code
const { shopifyAPIKey } = process.env.NEXT_PUBLIC_DEV_SHOPIFY_API_KEY
  ? { shopifyAPIKey: process.env.NEXT_PUBLIC_DEV_SHOPIFY_API_KEY }
  : await fetch('/config.json').then((response) =>
        ? response.json()
        : Promise.reject(new Error('cannot get config'))

Obviously this variable has to be specified only in the .env.development.local file to avoid being included into the built production bundle.

This project’s front-end does not receive a lot of traffic, so the overhead of the extra HTTP request is acceptable. Also, time to first meaningful paint is barely affected. If I were more sensitive to performance issues, I would instead inject the variable into the served HTML files. This would still be done by adding a second CDK bucket deployment source like above, but with a bit more smarts: like e.g. adding a script tag with settings before the closing body tag. For my use case though, the virtual JSON file is fast enough, elegant and easy to understand.