How to provide information about the backend environment to frontend clients

Environment variable-like functionality for client applications

Author's image
Tamás Sallai
5 mins
Photo by Anne Nygård on Unsplash

Environment informations on the frontend

A common challenge with applications with a backend-frontend separation (which is most apps out there) is how to communicate environment information to the frontend. The most common piece of information: where is the API?

Let's say the API is behind an API Gateway or provided by AppSync with a generated URL: https://<apiid>.appsync-api.eu-central-1.amazonaws.com/graphql. How does the frontend that was loaded from the domain example.com knows where to connect?

There are a couple of solutions to this common problem, and we'll discuss some of them in increasing sophistication.

Hardcoded values

The simplest one is to just hardcode the values into the frontend code. This is when you end up with environment-dependent configuration files and/or build scripts.

While this is a simple and straightforward approach, it has a couple of drawbacks. First, every new environment requires a new configuration file or build script to pass the current values to the build. But this is only a tooling problem that is relatively easy to live with.

The bigger problem is how to handle when these values change. For example, in some cases it might be necessary to redeploy the API that can change its generated URL. If it is hardcoded into the client application then it will break the moment you deploy the backend.

This is less of a problem for web applications as usually you can just update them with the new values the same time as you update the backend. There might be a slight downtime between the two deploys, but then a reload solves it for the clients.

But what if you can't deploy the client at the same time? For example, a mobile appplication might need an extensive review of unknown length to appear on the app store. In that case, updating the backend breaks the client.

Relative URLs

The second approach is to make the API URL relative to the frontend URL. For example, the app is available under example.com and the API URL is example.com/api or api.example.com. In this case, the frontend can use a relative URL or read its current location and construct the API URL from that.

This is a good solution for webapps that have a base URL, but in other cases it does not work. A mobile application does not have a base URL, for example. They will still need to hardcode that value.

Also, this approach is only viable for values that can be made relative to that base URL. For example, if the frontend needs to log in to an OpenID provider then it needs to know the client ID and that has nothing to do with URL known to the client.

Environment configuration file

The third approach is to provide a file at a known URL relative to the app URL with all the necessary information. This can be something like an example.com/environment.json that exports the API URL, client ID, and anything that is needed by the clients about the environment. This is similar to how the OpenID Connect Discovery works.

The upside of this approach is that it does not depend on the API's ability to support custom domains and it also works with arbitrary data.

While this approach still does not solve the mobile app case entirely, it at least makes it easier to handle as there is only a single value the application needs to know and any change is propagated. For example, if the Cognito client ID or the API URL changes then it will be picked up by restarting the app or even automatically.

Implementation

Let's see how to implement this approach for an AWS serverless solution!

Here, the frontend files are uploaded to an S3 bucket and a CloudFront distribution serves them to the browser:

resource "aws_s3_bucket" "frontend_bucket" {
  force_destroy = "true"
}

locals {
  # Maps file extensions to mime types
  # Need to add more if needed
  mime_type_mappings = {
    html = "text/html",
    js   = "text/javascript",
    mjs  = "text/javascript",
    css  = "text/css"
  }
}

resource "aws_s3_object" "frontend_object" {
  for_each = fileset("${path.module}/frontend", "*")
  key      = each.value
  source   = "${path.module}/frontend/${each.value}"
  bucket   = aws_s3_bucket.frontend_bucket.bucket

  etag          = filemd5("${path.module}/frontend/${each.value}")
  content_type  = local.mime_type_mappings[concat(regexall("\\.([^\\.]*)$", each.value), [[""]])[0][0]]
  cache_control = "no-store, max-age=0"
}

Add an extra file called config.mjs with some JS code exporting the values:

resource "aws_s3_object" "frontend_config" {
  key     = "config.mjs"
  content = <<EOF
export const cognitoLoginUrl = "https://${aws_cognito_user_pool_domain.domain.domain}.auth.${data.aws_region.current.name}.amazoncognito.com";
export const clientId = "${aws_cognito_user_pool_client.client.id}";
export const APIURL = "${aws_appsync_graphql_api.appsync.uris["GRAPHQL"]}";
EOF
  bucket  = aws_s3_bucket.frontend_bucket.bucket

  content_type  = "text/javascript"
  cache_control = "no-store, max-age=0"
}

And that's all, now the webapp can import this file and use the values:

import {cognitoLoginUrl, clientId, APIURL} from "./config.mjs";

Conclusion

Providing values from the environment to client applications requires some planning especially to handle cases when these values change. Making a configuration file available at a known and fixed location solves most of the problems and is the preferred solution for this.

June 28, 2023
In this article