Front-End Web & Mobile

Wildcard Subdomains for Multi-tenant Apps on AWS Amplify Hosting

AWS Amplify Hosting is excited to announce the general availability of wildcard subdomains when using a custom domain with your Amplify application. This is critical for developers that are building personalized user experiences in their Software as a Service (SaaS) or multi-tenant platforms.

This new capability is available for any application deployed to Amplify Hosting using a custom domain including static applications, single page applications (SPA), and fullstack Server-side rendering applications using Next.js. This functionality not only simplifies the process of creating dynamic, customer-specific subdomains but also allows for more potential customization of your applications. Wildcard subdomains allow for the creation of “catch-all” subdomains using a “*” wildcard that route traffic to a branch in your Amplify application. This is a common pattern in SaaS applications that require their own unique subdomain identifier and allows the applications to remain flexible when onboarding (and off boarding) customers or accounts.

Solution Overview

In this post, we’ll dive into how to build a Next.js server-side rendering (SSR) app on Amplify Hosting that leverage these wildcard subdomains. We’ll take a look at the process of capturing subdomain identifiers with Next.js middleware and how this capability can be integrated into an app architecture.

As a practical example, we’ll build a minimum viable ‘Link in Bio’ type application. This app uses Amplify authentication and a GraphQL API to create user accounts that are instantly accessible as distinct subdomain routes. This architecture pattern can be extended to support a variety of multi-tenant applications that serve multiple customers across different subdomains with a single codebase like blogging, e-commerce, and custom web site platforms.

Deploy a Next.js app with a wildcard subdomain

Prerequisites

To get started with wildcard subdomains, you’ll need:

  1. A custom domain to use with the application
  2. Access to the Domain Name System (DNS) configuration for this domain

First, we’ll deploy a Next.js SSR application with AWS Amplify Hosting, utilizing wildcard subdomains. This setup will allow the application to dynamically handle requests, such as *.example.com . The asterisk (“*”) will match to any of the valid values that are included in the request.

We’ll use GitHub for the deployment, so we will push our code changes to a GitHub repository,then create an application using Amplify Hosting CI/CD to connect to the repository and build the application.

Create a new Next.js application

Begin by creating a default Next.js SSR app using create-next-app. Note, that this application will use the App Router.

npx create-next-app@latest with-wildcard-subdomains --app

For reference, below is the package.json. The structure of the project should be configured for SSR. The scripts section should match below:

{
  "name": "with-wildcard-subdomains",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "^14.0.1",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10",
    "eslint": "^8",
    "eslint-config-next": "13.5.6",
    "postcss": "^8",
    "tailwindcss": "^3",
    "typescript": "^5"
  }
}

Create a new repo in GitHub and push the newly created project to it.

Deploy a new App in Amplify Hosting

We’ll use the Host a web app flow in Amplify Hosting. When creating the application there are a few things to configure.

First, connect to the application repository in your GitHub account. Once connected, Amplify Hosting will fork the repository in your account and ask you to confirm deployment.

Host a new web app and name it

Make sure the app is recognized as a Server-side rendering deployment. The build command will be npm run build and the baseDirectory value should be .next.

Choose a or create an IAM role for Server-side rendering logs. This will allow you to collect Server side logs in your Amazon CloudWatch account. So, any console.log s in the React server components or API routes will log out to your account.

In the Build image settings section use amplify:al2023 as the value for the custom build image if this is the first deployment. If the app has already been deployed, then select the Amazon Linux: 2023 image from the dropdown.

Select the Amazon Linux 2023 build image

Click Next to deploy the application. The app will build and deploy the full-stack SSR application. The runtime environment of the server side compute runtime will match the runtime used during the build.

The Amplify Hosting CI/CD pipeline

After the CI/CD pipeline is finished, the application will be hosted at an amplifyapp.com domain. Now, we’ll add a custom domain.

Configuring a Wildcard Subdomain

Setting up a custom domain

In the navigation pane, choose App Settings > Domain management. Add your custom domain and configure it.

Configure a custom domain default

Note: As an example, I’m using example.com as my domain value. Use a domain here that you own or one that you’re able to make updates the DNS records.

Add a custom domain

To add the wildcard subdomain, click Add and enter an asterisk * as the value. In this scenario, requests made to www.example.com will be mapped to the main branch. Additionally, any other subdomain value will also be matched by the asterisk and map traffic to the main branch.

Add a wildcard subdomain to the custom domain

Update your DNS to verify ownership. After a few moments, Amplify Hosting will create and configure SSL for the domain.

Custom domain SSL configuration

Once this complete and verified, you’ll need to add two more additional CNAME records to account for the configured subdomains. Once configured and saved in your DNS, the custom domain status will switch to Available.

Successful custom domain configuration

The wildcard subdomain is now live! The application is accessible at any of the subdomain values. So, now when a user accesses the application, the application middleware will reroute the request to the index page of the application. After deploying, go ahead and try to access any subdomain value other than www – you’ll be directed to the main root index page.

By following the steps above, the Next.js application will handle dynamic subdomains, making it ideal for SaaS-type apps.

Next steps – Building a Link-in-Bio App

We’re ready to create a minimum viable “Link in Bio” app having now deployed a branch, setup a custom domain, and wildcard subdomains. This app will enable users to sign up with a unique username and access their personal bio page via subdomain (i..e . <username>.<domain>.com ), which dynamically redirects to their profile.

We’ll use Next.js middleware for handling the subdomain parsing and request redirection. This feature allows us to intercept and modify requests in a Next.js app. Additionally, we’ll integrate user authentication using Amplify JS and the pre-built Amplify UI Authenticator component.

To do this, we’ll follow these steps:

  1. Create and pull an Amplify backend into the Next.js project using the Amplify CLI
  2. Add Amplify Auth
  3. Add a GraphQL API to persist data
  4. Allow users to sign up to the application and select a username for direct navigation
  5. Add in middleware to capture the subdomains and rewrite requests to the correct page

Enable an Amplify Backend for your application by clicking Get Started > Backend environments tab of the application.

Pull the Amplify backend into your local environment using the Local setup instructions:

amplify pull --appId <app-id> --envName staging

When prompted, follow the steps to log into Amplify Studio to link your local frontend app with the new Amplify backend. After returning back to the command line, make the prompt selections, finishing with:

Do you plan on modifying this backend? Yes

Now, we can continue build out the application.

Application Structure

To add the required functionality, we’ll modify the structure of our Next.js 14 application, which uses the App Router. The updates include:

  1. Amplify Authenticator integrated at the /login route will enable user registration, login, and profile updates
  2. The BioForm component for user’s to update their bio profile information
  3. The /users/[username] route will be the route that requests. When a user’s subdomain matches  [username] , the request is directed here.
  4. The middleware.ts will handle the request interception and map it to the correct path.
 .
+├── amplify/
 ├── app/
+│   ├─ (auth)/
+│   │     └─ login/
+│   │         ├─ BioForm.tsx
+│   │         └─ page.tsx
+│   │
+│   ├─ users/
+│   │   └─ [username]/
+│   │       └─ page.tsx     
 │   ├─ layout.tsx
 │   ├─ page.tsx
 │   └─ ...
+├── middleware.ts
 ├── node_modules/
 ├── public/
 ├── next.config.js 
 ... 
 └─ README.md

Adding Amplify Auth

Add in Amplify Auth. Select username as the sign in mechanism.

amplify add auth

Add a GraphQL API

Now that we have the concept of a user, we will add an API to persist some data that the user can populate their bio page with. We’ll add a GraphQL API with a minimal data model to capture a bio description and a bio link.

amplify add api 

In schema.graphql, add the User schema. This will allow users to create, read, and update their profile record while also allowing public access to read the profile.

type User
  @model
  @auth(rules: [{ allow: public, operations: [read] }, { allow: owner }]) {
  id: ID!
  username: String! @index(name: "byUsername", queryField: "byUsername")
  description: String
  link: String
}

Push the changes to your cloud backend. This will also push the previous Auth changes too.

amplify push

Creating the UI

Next, we’ll integrate the Amplify JS and Amplify UI libraries. This will allow the application to make use of the cloud resources that were just provisioned. We’ll use the <Authenticator> component for user registration. Additionally, the Amplify JS library will interact with the GraphQL API through mutations and queries.

To begin building out the UI, install the following dependencies:

  1. npm install aws-amplify@6
  2. npm install @aws-amplify/ui-react

Adding Authentication

The default Authenticator is used with the additional field-level validation. The username is validated using subdomainRegex since the username will also be a subdomain reference.

"use client";

// ...imports...

export default function App() {
  return (
    <div>
      <Authenticator
        initialState="signUp"
        components={{
          SignUp: {
            FormFields() {
              return (
                <>
                  <Authenticator.SignUp.FormFields />
                </>
              );
            },
          },
        }}
        services={{
          async validateCustomSignUp(formData) {
            // this is important
            if (!formData.username) {
              return {
                acknowledgement: "Username is invalid.",
              };
            }

            // match subdomain pattern
            const subdomainRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

            // check if subdomain is valid
            if (!subdomainRegex.test(formData?.username)) {
              return {
                acknowledgement: "Username is invalid.",
              };
            }
          },
        }}
      >
        {({ signOut, user }) => (
          <div>
            <main>
              <h1>Hello, {user?.username}.</h1>
              <div>
                <button onClick={signOut}>Sign out</button>
              </div>
            </main>
          </div>
        )}
      </Authenticator>
    </div>
  );
}

Once the Authenticator is set up with the new route, you’ll have a view like below before creating an account or signing in on the app.<domain>/login route:

New app login page

And after signing in with a username (i.e. stephen).

Authenticated user page

Adding in the profile Bio form

We’ll add in a simple form to capture a user description and link field for public display on their bio page.

Since we initially only have the user’s username, this field is pre-populated in the form for easy lookup in the GraphQL API. The GraphQL API achieves this with the index on the username.

Create a new component BioForm.tsx and include it in the existing login page. A minimal version may resemble:

// /app/(auth)/login/BioForm.tsx
"use client";

import { useEffect, useState } from "react";
import type { FormEvent } from "react";

import { Amplify } from "aws-amplify";
import { createUser, updateUser } from "@/src/graphql/mutations";
import * as queries from "@/src/graphql/queries";
import { getCurrentUser } from "@aws-amplify/auth";
import { generateClient } from "aws-amplify/api";

import awsExports from "@/src/aws-exports";

Amplify.configure(awsExports);

const client = generateClient();

export default function BioForm() {
  const [loggedInUser, setLoggedInUser] = useState<any>(null);
  const [userProfile, setUserProfile] = useState<any>(null);
  const [userProfileExists, setUserProfileExists] = useState<boolean>(false);
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const form = new FormData(event.currentTarget);
    const description = form.get("description") || userProfile?.description;
    const link = form.get("link") || userProfile?.link;

    if (userProfileExists) {
      await onUpdate({
        id: userProfile?.id,
        username: loggedInUser?.username,
        description,
        link,
      });
    } else {
      await onCreate({
        username: loggedInUser?.username,
        description,
        link,
      });
    }
  }

  // include mutation handlers to update user profile
  const onCreate = async (formData: {
    username: string;
    description: string;
    link: string;
  }) => {
    const { username, description, link } = formData;
    const input = { username, description, link };
    await client.graphql({
      query: createUser,
      variables: { input },
      authMode: "userPool",
    });
  };

  const onUpdate = async (formData: {
    id: string;
    username: string;
    description: string;
    link: string;
  }) => {
    const { id, username, description, link } = formData;
    const input = { id, username, description, link };
    await client.graphql({
      query: updateUser,
      variables: { input },
      authMode: "userPool",
    });
  };

  const getUserProfile = async (username: string) => {
    return await client.graphql({
      query: queries.byUsername,
      variables: { username },
      authMode: "userPool",
    });
  };

  useEffect(() => {
    const getUser = async () => {
      const user = await getCurrentUser();
      if (user) {
        setLoggedInUser(user as any);

        const { data } = await getUserProfile(user?.username);

        const profile = data?.byUsername?.items[0];

        if (profile) {
          setUserProfileExists(true);
          setUserProfile(profile as any);
        }
      }
    };

    getUser();
  }, []);

  return (
    <form onSubmit={onSubmit}>
      <div>
        <input
          type="text"
          name="username"
          id="username"
          value={loggedInUser?.username}
          disabled={true}
        />
      </div>

      <div>
        <textarea name="description" id="description" />
      </div>

      <div>
        <label htmlFor="link">Link</label>
        <input type="url" name="link" id="link" />
      </div>

      <div>
        <button type="submit">
          {userProfileExists ? "Update" : "Create"}
        </button>
      </div>
    </form>
  );
}

Update the current login page to include the new component. Now, when users log in, they will be presented with the profile form.

User Bio Profile form

Create the Link-in-Bio Page

When end users visit a route with a username subdomain, the request will be rewritten to the the /users route. The username acts as a dynamic parameter to fetch and display the user’s profile. To implement this functionality, create a public page at app/users/[username]/page.tsx to display the users bio profile information. Using the Next.js route parameter, the user’s profile can be queried with Amplify JS. For example:

const { data } = await client.graphql({
  query: queries.byUsername,
  variables: { username },
})

Multi-tenant routing with Middleware

This is an example middleware.ts handler to demonstrate matching on subdomains and then rewriting the request to the correct application page. The requests will be routed to the appropriate user profile page if they include a subdomain value and the login page will be served on the app. subdomain.

import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: [
    /*
     * Match all paths except for:
     * 1. /api routes
     * 2. /_next (Next.js internals)
     * 3. /_static (inside /public)
     * 4. all root files inside /public
     */
    "/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)",
  ],
};

export default async function middleware(req: NextRequest) {
  const url = req.nextUrl;

  const hostname = req.headers.get("host")!;

  const path = url.pathname;

  let subdomain = hostname.split(".")[0];

  subdomain = subdomain.replace("localhost:3000", "");

  // handle no subdomain or www with base path
  if ((subdomain === "www" || subdomain === "") && path === "/") {
    return NextResponse.rewrite(new URL("/", req.url));
  }

  // profile login
  if (subdomain === "app" && path === "/login") {
    return NextResponse.rewrite(new URL("/login", req.url));
  }

  // subdomains
  if (subdomain !== "app") {
    return NextResponse.rewrite(
      new URL(`/users/${subdomain}${path === "/" ? "" : path}`, req.url)
    );
  }

  return NextResponse.next();
}

Now, when a request is received by the application, the middleware will:

  1. Check if the path has a subdomain of app and a path of /login.
  2. Check for subdomains that do not match “app”.
  3. Or, fall back to passing the original request along.

Now the user profile pages are up and running after a user is created! For example, visiting stephen.localhost:3000 would now show profile page if the user exists.

Push and deploy to Hosting

To deploy the updated app, including both frontend and backend changes, begin by pushing the changes to your git repository. You’ll need to update the IAM service role configured with the application to allow for Amplify Backend builds. This can be updated in the App settings > General configuration.

Once that’s done, link the front end to the backend to enable continuous deployments.

Link Amplify frontend to the new staging backend

In App settings > Build image settings, select the appropriate Live Package Updates for the Amplify CLI version.

Now, when you push commits, the frontend and backend will deploy.

Clean up

To wrap up, delete the application if it’s no longer needed. To do this, go into App settings > General in the Amplify Hosting Console for this app and click Delete app. Also, make sure remove any DNS records from your domain if you plan to reuse for another application.

Conclusion

Using wildcard subdomains is an effective way to scale a multi-tenant architecture on Amplify Hosting. By integrating a wildcard subdomain setup with your dynamic or SSR applications, you can create customized experiences with minimal configuration as your SaaS apps scale.

For more information, visit the AWS Amplify Hosting documentation for wildcard subdomains.