How to Build a Cloud Version of Your Open Source Software - A Case Study with AppWrite - Part 3

Open-source eat the world. More and more great open-source projects are used. One standard method to make those products financially sustainable is to provide a managed version. Meaning, you can enjoy using their product without the hassle of managing the product updates, the backups, the security, and the scaling. This guide will attempt to explain how to build a cloud-managed version of an open-source project.

In this part of the series, we will go through the process of adding a web user interface to our AppWrite Cloud platform. We’ll use the Next.js framework to create the frontend application, connect it to our AppWrite Cloud GraphQL API and deploy the app on top of Qovery.

Bootstrapping Frontend

In the first step, let’s create a scaffolding to our frontend application:

yarn create next-app --example with-tailwindcss frontend

We use Tailwind for styling, so the command above bootstraps a Next.js app with TailwindCSS already set up.

After the scaffolding is created, create a new remote Git repository on Github, Gitlab or Bitbucket with the application code.

For building and deploying the app on Qovery, we’ll use Docker - to dockerize the application please add a Dockerfile with the following content:

# Install dependencies only when needed
FROM node:alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN yarn build && yarn install --production --ignore-scripts --prefer-offline
# Production image, copy all the files and run next
FROM node:alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# You only need to copy next.config.js if you are NOT using the default configuration
# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry.
# ENV NEXT_TELEMETRY_DISABLED 1
CMD ["yarn", "start"]

The Dockerfile will let Qovery know how to build and run the application. After the Dockerfile is created, add a new application in the AppWrite Cloud project on Qovery with port 3000 and Docker build mode:

AppWrite Qovery Case Study

In the next step let’s add a APPWRITE_GRAPHQL_BACKEND env variable that we will, later on, use in our frontend. This variable will be an alias to the location of our Hasura application, so we can access its GraphQL API:

AppWrite Qovery Case Study

Connecting Backend

Now to quickly deploy the app and test the integration, let’s replace the content of index.tsx with the following:

import { Disclosure, Menu, Transition } from '@headlessui/react';
import { BellIcon, MenuIcon, XIcon } from '@heroicons/react/outline';
import axios from 'axios';
import { Fragment, useState } from 'react';
import { useMutation } from 'react-query';
const anonymous = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLXVzZXItaWQiOiI1IiwieC1oYXN1cmEtZGVmYXVsdC1yb2xlIjoiYW5vbnltb3VzIiwieC1oYXN1cmEtYWxsb3dlZC1yb2xlcyI6WyJhbm9ueW1vdXMiXX0sImV4cCI6MTY2NjA3NzAwNn0.Op7qVJAlMm3O2p1sSTMueuTUoUJls1K4pdmiusaz1R0"
const user = {
name: 'Tom Cook',
imageUrl:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
const navigation = [{ name: 'Dashboard', href: '#', current: true }];
const userNavigation = [
{ name: 'Your Profile', href: '#' },
{ name: 'Settings', href: '#' },
{ name: 'Sign out', href: '#' },
];
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
}
export default function Dashboard() {
return (
<div className="min-h-full">
<div className="bg-gray-800 pb-32">
<Disclosure as="nav" className="bg-gray-800">
{({ open }) => (
<>
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-gray-700">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<div className="flex items-center">
<div className="flex-shrink-0">
<img
className="h-8 w-8"
src="https://tailwindui.com/img/logos/workflow-mark-indigo-500.svg"
alt="Workflow"
/>
</div>
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'px-3 py-2 rounded-md text-sm font-medium'
)}
aria-current={item.current ? 'page' : undefined}
>
{item.name}
</a>
))}
</div>
</div>
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6">
<button
type="button"
className="bg-gray-800 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
>
<span className="sr-only">View notifications</span>
<BellIcon className="h-6 w-6" aria-hidden="true" />
</button>
{/* Profile dropdown */}
<Menu as="div" className="ml-3 relative">
<div>
<Menu.Button className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<span className="sr-only">Open user menu</span>
<img
className="h-8 w-8 rounded-full"
src={user.imageUrl}
alt=""
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
{userNavigation.map((item) => (
<Menu.Item key={item.name}>
{({ active }) => (
<a
href={item.href}
className={classNames(
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700'
)}
>
{item.name}
</a>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
<div className="-mr-2 flex md:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<span className="sr-only">Open main menu</span>
{open ? (
<XIcon
className="block h-6 w-6"
aria-hidden="true"
/>
) : (
<MenuIcon
className="block h-6 w-6"
aria-hidden="true"
/>
)}
</Disclosure.Button>
</div>
</div>
</div>
</div>
<Disclosure.Panel className="border-b border-gray-700 md:hidden">
<div className="px-2 py-3 space-y-1 sm:px-3">
{navigation.map((item) => (
<Disclosure.Button
key={item.name}
as="a"
href={item.href}
className={classNames(
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'block px-3 py-2 rounded-md text-base font-medium'
)}
aria-current={item.current ? 'page' : undefined}
>
{item.name}
</Disclosure.Button>
))}
</div>
<div className="pt-4 pb-3 border-t border-gray-700">
<div className="flex items-center px-5">
<div className="flex-shrink-0">
<img
className="h-10 w-10 rounded-full"
src={user.imageUrl}
alt=""
/>
</div>
<div className="ml-3">
<div className="text-base font-medium leading-none text-white">
{user.name}
</div>
<div className="text-sm font-medium leading-none text-gray-400">
{user.email}
</div>
</div>
<button
type="button"
className="ml-auto bg-gray-800 flex-shrink-0 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
>
<span className="sr-only">View notifications</span>
<BellIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="mt-3 px-2 space-y-1">
{userNavigation.map((item) => (
<Disclosure.Button
key={item.name}
as="a"
href={item.href}
className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700"
>
{item.name}
</Disclosure.Button>
))}
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
</div>
</header>
</div>
<main className="-mt-32">
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<Signup />
<Signin />
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
<div className="border-4 border-dashed border-gray-200 rounded-lg h-96" />
</div>
</div>
</main>
</div>
</>
);
}
const Signin = (email, password) => {
const mutation = useMutation((event) => {
event.preventDefault();
return axios({
url: graphqlApiEndpoint,
method: 'post',
headers: { 'Authorization': 'Bearer ' + anonymous },
data: {
query: `
query Singin {
Singin(email: "${email}", password: "${password}") {
accessToken
}
}
`,
},
})
});
return <button onClick={(event) => mutation.mutate(event)}>Login</button>;
};
const Signup = (email, password) => {
const mutation = useMutation((event) => {
event.preventDefault();
return axios({
url: graphqlApiEndpoint,
method: 'post',
headers: { 'Authorization': 'Bearer ' + anonymous },
data: {
query: `
query Signup {
Signup(email: "${email}", password: "${password}") {
accessToken
}
}
`,
},
})
});
return <button onClick={(event) => mutation.mutate(event)}>Signup</button>;
};

This makes the skeleton of our UI:

AppWrite Qovery Case Study

Clicking on the signup will send a test signup request to our backend - click Signup and see the response with an access token in the network tab of your browser:

AppWrite Qovery Case Study

To send the request, we use the following piece of code:

axios({
url: graphqlApiEndpoint,
method: 'post',
headers: { Authorization: 'Bearer ' + anonymous },
data: {
query: `
mutation {
Signup(input: {email: "${email}", password: "${password}"}) {
accessToken
}
}
`,
},
}

We use axios HTTP library to send a POST request to our graphqlApiEndpoint (that uses the value of the environment variable we set previously) to run a GraphQL mutation that creates a new user with a given email and password in our AppWrite Cloud backend. In the response, we receive an access token that we can use in the name of the user to interact with the API.

The anonymous token sent in the request is a way to interact with unauthenticated endpoints in the Hasura backend.

In the next step let’s take care of the list of user projects:

const { isLoading, error, data } = useQuery('projects', () => {
return axios({
url: graphqlApiEndpoint,
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
data: {
query: `query Projects {
project {
id
name
url
}
}
`,
},
});
});

In the snippet above, we use ReactQuery to manage the server state (store the info about the project client-side) and axios for performing the HTTP request. In the headers, we send users’ accessToken, and the payload allows us to specify data that we are interested in about projects we have access to.

The response from the query contains info we can use to render the list of AppWrite projects managed by AppWriteCloud:

AppWrite Qovery Case Study

Now, to display it, add the following piece of code into our dashboard component:

{appwriteProjects.map((project) => (
<div
key={project.id}
className="relative rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm flex items-center space-x-3 hover:border-gray-400 focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>
<div className="flex-1 min-w-0">
<a href="#" className="focus:outline-none">
<span
className="absolute inset-0"
aria-hidden="true"
/>
<p className="text-sm font-medium text-gray-900">
{project.name}
</p>
<a
href={project.url}
className="text-sm text-gray-500 truncate hover:text-lg"
>
{project.url}
</a>
</a>
</div>
</div>
))}

This should allow us to display a list of user’s projects in the dashboard like this:

AppWrite Qovery Case Study

After improving the sign in form (see the whole code repository here, the whole flow should now look like this:

AppWrite Qovery Case Study

Conclusion

In this article, we bootstrapped a frontend application and added it to our app write cloud. We created the first version of our frontend that makes use of React, Next.js, ReactQuery and Tailwind. The UI is integrated with our backend GraphQL API that is deployed on Qovery and allows us to manage AppWrite projects deployed on AWS for AppWrite Cloud clients.