loading...

Deploy multiple web apps using a single code-base with FaunaDB

cuadra profile image Jorge Cuadra ・9 min read

Have you ever tried reusing the same core components for a few apps? Did you try a mono-repo? Or what about building an npm package with all your design system components. There are even solutions like bit.dev components, which could be very helpful. I did too, multiple times, but it wasn't a good fit for me.

I ended up using just one repo, one code-base. Let's call it a data-driven approach. I intended to reuse the code of our app for multiple organizations where each organization has its authentication and database. In this article, I'm going to walk you through my implementation of deploying multiple apps while using the same code-base.

Techstack:

  • Fauna,
  • NextJS,
  • ReasonReact,
  • Vercel
  • Github

My company's strategy to increase software leverage

My company is composed of three different business units. Each business unit has its branding and offers a unique set of products for the construction industry.

  1. Brezza manufactures retractable insect screens.
  2. FentexHaus offers PVC windows for acoustic and thermal insulation.
  3. TurboRoof commercializes roofing shingles.

In late 2019 we decided to evolve our traditional business model by embracing the potential of e-commerce and software automation. To achieve this, we need to run fast and lean. Small businesses can be budget-sensitive when considering building custom software. The goal is to have custom apps that multiply the output of our staff, while at the same time avoiding to overspend our budget for software development.

The web apps that we are building

The first app is a quote estimator for roofing products. It allows my company's salespeople to select an item, color, and quantity. Could they use a spreadsheet? We tried that, but it was fragile to maintain and hard to scale.

TurboRoof App

The second app is a quote estimator for insect screens. A product configurator allows our salespeople to customize the products with characteristics such as height and width, color, amount of panels, and the opening mechanism. The UI layout consists of two panes positioned side by side. The left pane displays the preview of the product concerning the selected configurations. The panel at the right contains input fields and selection buttons to trigger the product configurations.

Brezza Insect Screens

The logic between clients and servers

The architecture consists of three Fauna databases and an isomorphic NextJS app hosted in Vercel. By isomorphic, I mean that NextJS runs both in the client and the server. The same app serves three URLs. On the back-end, NextJS talks to the databases. Similarly, on the client, NextJS fetches only one database, and RouterDB remains out of reach. In the following diagram, you can see how the network lays out.

Network diagram

For example, if a user navigates to https://shop.turboroof.com, NextJS client will get data only from the TurboRoofDB. NextJS server will tell the client to fetch from that database. RouterDB is in charge of telling NextJS Server from which database it should query. In the sequence diagram below, you can see how the units communicate to figure out the corresponding database.

Sequence diagram

Depending on the retrieved configuration, the client-side app toggles logic, components, and views. For example, it will know that it has to show the product configurator for the insect screens app, but replace it with a traditional e-commerce catalog if handling the roofing case. The same goes for smaller customizations like the logo and third-party API keys.

Starting with the databases

To make the most of my time, I figured I had to use a maintenance-free database to minimize time spent on DevOps and dependency maintenance.

Even though I do full-stack development, my sharper skills are on the frontend. I believe that that makes me more comfortable with NoSQL databases. Therefore, I automatically discarded the classics such as MySQL and PostgreSQL. I wanted something closer to how I would write Javascript to have a shorter learning curve and less context switching. During my exploration, I tried out Mongo, GraphCool, Prisma, and finally, Fauna.

Mongo was exciting at first. They have a cloud product called Atlas, which took away the task of maintaining a database. Their Javascript driver makes it convenient to write mongo queries. They also have a free-tier, which was helpful to try out. The big drawback was that I had to map each mongo-query to an HTTP request. That was a lot of extra work. Also, I wanted to use GraphQL to speed up the development of the data queries. That led me to look for other products that offered GraphQL integration.

I found GraphCool, which later turned into Prisma. Their GraphQL support was amazingly powerful. They transform your GraphQL schema into a full-blown GraphQL server with filters and sorting features built-in. The problem with this product was that it requires an elaborate setup consisting of multiple servers and a database. Back then, they were about to support Mongo, but their stable integrations were with MySql and PostgreSQL. That meant that I wasn't relieved of the maintenance burden and had to find hosting for the servers needed for the whole system to work.

Hasura and DGraph have been on my radar as well. They both seem like they didn't prioritize a cloud offering and a robust way to handle customized queries. I do think they are great products, but their unprioritized cloud offering has been my main objection.

Fauna's serverless nature and its GraphQL out-of-the-box feature turned out to be an excellent fit for my setup. I save a lot of time not having to maintain and upgrade the database. The GraphQL schemas conveniently turned into a GraphQL server, relieving me from taking care of it. Plus, with Fauna's UDFs (User Defined Functions), I can easily connect them to GraphQL when I need to add custom filters, search, or complex mutations.

Setting up the databases

I started by creating a database with two child databases. The parent database contains information about the children databases:

  1. the name of the subdomain in which they will show up,
  2. and their server key from Fauna.

I set it up manually, but I believe I could automate it with FQL.

Fauna RouterDB

Fauna stats

Each child database has a Setting collection that contains org specific settings such as logoUrl, 3rd party API keys (such as headwayapp.co), feature flags, and any others that the app might need within this scope. These settings get passed to NextJS as "initial props" in the app's root level. From there, you can redistribute them with your favorite state management or prop drilling (if your app is shallow enough). My latest personal preference is RecoilJS, which I think is the most convenient state management system.

type Query {
  customers: [Customer!]
  itemPipelines: [ItemPipeline!]
  quotes: [Quote!] 
  settings: [Setting!] 
}

type Setting {
  config: String!
  id: String! @unique
}

Client-side

With the org settings in React's state, you can toggle components, views, and assets. Every org can have its data-driven customizations such as logo, color pallet, business logic, and layout.

Following this implementation method allowed me to build the two other apps for two different companies while sharing the same source code and one-click deployments. Both apps conveniently share the same design system and React components. This convenience makes me more productive as a developer since I don't need to handle the overhead maintenance of hosting the shared components in an npm package and the dependencies of the (n + 1) three repositories.

The NextJS app will load _app.js to run a server-side HTTP request to a serverless function /api/org to fetch the data from that subdomain. The serverless function will parse the subdomain from the HTTP-request and checks at the parent database to get the matching orgByNamespace. With Fauna's secret key, NextJS can now fetch metadata from the matching child database by inserting the key in the GraphQL auth header.

import { RecoilRoot } from 'recoil';
import React from 'react';
import App from 'next/app';
import { SWRConfig } from 'swr';
import { GraphQLClient } from 'graphql-request';
import { print } from 'graphql/language/printer';

import '../css/tailwind.css';
import AppLayout from '../layouts/AppLayout';
import AppShell from '../components/chrome/AppShell';

class MyApp extends App {
  static async getInitialProps({ req }) {
    const host = req
      ? req?.headers['host']
      : typeof window !== 'undefined'
      ? window.location.host
      : '';
    if (!host) return { org: {} };

    const isLocalHost = host.includes('localhost');
    const domain = isLocalHost ? 'http://' + host : 'https://' + host;

    const res = await fetch(domain + '/api/org');
    const json = await res.json();
    return { org: json };
  }

  render() {
    const { Component, pageProps, org = {} } = this.props;
    let appType = org?.settings?.appType || '';

    const layoutConfig = Component.getLayoutSwitch
      ? Component.getLayoutSwitch({ appType })
      : {
          getLayout:
            Component.getLayout || ((page) => <AppLayout children={page} />),
        };

    const fetcher = (query, source = 'FAUNA', variablesStringified) => {
      const graphQLClient = ((src) => {
        switch (src) {
          case 'FAUNA':
          default:
            return new GraphQLClient('https://graphql.fauna.com/graphql', {
              headers: {
                authorization: `Bearer ${org?.serverSecret}`,
                'X-Schema-Preview': 'partial-update-mutation',
              },
            });
        }
      })(source);
      const parsedQuery = typeof query === 'string' ? query : print(query);
      try {
        // Needs to be flat to avoid unnecessary rerendering since swr does shallow comparison.
        const variables =
          typeof variablesStringified === 'string'
            ? JSON.parse(variablesStringified)
            : variablesStringified;

        return graphQLClient.request(parsedQuery, variables);
      } catch (err) {
        return graphQLClient.request(parsedQuery, {});
      }
    };
    if (Component.isPublic || layoutConfig.isPublic)
      return (
        <RecoilRoot>
          <SWRConfig value={{ fetcher }}>
            {layoutConfig.getLayout(
              <Component {...pageProps} appType={appType} />,
            )}
          </SWRConfig>
        </RecoilRoot>
      );

    // redirect if the subdomain is unknown
    if (!org?.serverSecret && typeof window !== 'undefined') {
      window.location.href = 'https://turboroof.com';
    }
    return (
      <RecoilRoot>
        <SWRConfig value={{ fetcher }}>
          <AppShell fetcher={fetcher} org={org}>
            {layoutConfig.getLayout(
              <Component {...pageProps} appType={appType} />,
            )}
          </AppShell>
        </SWRConfig>
      </RecoilRoot>
    );
  }
}

export default MyApp;

Feature Toggles

To simplify the conditionals, I built a Can-component and an If-component. I use the Can-component when permissions trigger the toggle at the org or user level. I borrowed the implementation from the Auth0 blog post. The If-component is an attempt to have cleaner conditionals, although I have some concerns about its performance.

const If = ({ children, orThis, it }) => {
   return it ? children : orThis;
}

// usage example

<div>
  <If it={age > 18} orThis={"🥤"}> 🍺 </If>
</div>

How to deploy the app to each subdomain

Vercel powers the deployment. The steps to deploy are simple. There are usually just two git branches: master and canary. I mainly develop in the canary git-branch. When I push the git-branch to GitHub, it triggers a staging deployment to run automated end-to-end tests. If the build succeeds and the tests pass, I will open a pull request to the master branch. After promptly checking the code differences, I merge the Pull-Request. The merge triggers the deployment to production.

In Vercel, I set up a project linked to this GitHub repo. In the project's configuration, I set it to deploy to specific URLs that have unique subdomains. You can even target different domains if you want, as long as the subdomains are other.

Vercel project domains

A lambda function serves the org metadata. This function uses FQL to call the RouterDB and ask for the metadata that matches the requested subdomain. The FQL call uses the ROUTER_DB key obtained through an environment variable populated by Fauna-Vercel integration.

Fauna Vercel integration

With this setup, every time I deploy the app in this Vercel project, the new instance serves all the assigned URLs, and the server morphs the rendered HTML and configuration accordingly. In this manner, we can have multiple apps sharing the same code base, the same Vercel project, but with their unique databases, layouts, and business logic.

import faunadb from 'faunadb';
import keyBy from 'lodash/keyBy';
import { getSubdomain } from '../../api-utils/url';

const q = faunadb.query;

// process.env.FAUNADB_SECRET is the server secret for RouterDB
export default async function org(req, res) {
  const adminClient = new faunadb.Client({
    secret: process.env.FAUNADB_SECRET,
    keepAlive: false,
  });

  const host = req?.headers['host'];
  const subdomain = getSubdomain({
    host,
    processSubdomain: process.env.SUBDOMAIN,
  });

  try {
    const matches = await adminClient.query(
      q.Paginate(q.Match(q.Index('orgsByNameSpace'), q.Casefold(subdomain))),
    );
    const [appType, serverSecret] = matches?.data[0];

    const childClient = new faunadb.Client({
      secret: serverSecret,
      keepAlive: false,
    });

    const settingsList = await childClient.query(
      q.Map(q.Paginate(q.Match(q.Index('settings'))), (setting) =>
        q.Select(['data'], q.Get(setting)),
      ),
    );

    const settings = { ...keyBy(settingsList?.data || [], 'id'), appType };

    res.json({ settings, serverSecret, subdomain });
  } catch (error) {
    console.error(error);

    res.status(error.status || 500).json({
      error: error.message,
    });
  }
}

In summary

The motto of "work smart, not hard" has enabled us to do more with less. Choosing the right set of tools can speed up effectively, the possibility of reusing code for multiple use cases. As a solo-developer with limited resources, this approach allows me to build and maintain several apps on the whole full-stack scope.

This article revealed to you how I deploy different apps in their domains by leveraging the same source code. I explained how I use Fauna's child databases to store the configurations from each app. You saw how the router database matches the subdomain request with the respective app settings using Fauna-Vercel integration and a serverless function. Later, we demonstrated how each app uses its metadata to toggle features within the NextJS client UI. Finally, I pinpointed how Fauna's token-based database targeting makes it possible to have a convenient way of fetching and mutating data from the matching database by swapping the secret key in the HTTP Authorization header.

I hope this use-case implementation was helpful to present another way of orchestrating diverse applications by leveraging Fauna's powerful features.

If you have any questions you can reach me on Twitter: @Cuadraman

Discussion

pic
Editor guide