Offline-first made easy with GraphQL, Amplify DataStore and Vue
Create a PWA cloud-enabled offline-first chatroom using Vue
In this article, you will create Chatty, a chatroom Progressive Web App (PWA) that stores data on the device while offline, and synchronises in real-time with other devices when online using Amplify DataStore, GraphQL and Amazon DynamoDB. We will cover:
- Introduction to Offline-first, PWAs and Amplify DataStore
- Setting up a new project with the Vue CLI
- Creating a new GraphQL API
- Amplify DataStore: setup, data models and usage
- Creating the UI with Vue: the chatroom, sending and deleting messages; and doing real-time with subscriptions
- Making Chatty a PWA
- Adding a PWA custom configuration
- Improving UX while offline
- Publishing your app via the AWS Amplify Console
- Installing Chatty in the Desktop and Mobile
- Cleaning up cloud services
In order to follow this post you will need a basic knowledge of GraphQL. You can learn the basics following this tutorial at graphql.org. We will be referring to GraphQL schema, directives, types, queries, mutations, subscriptions and resolvers.
Please let me know if you have any questions or want to learn more at @gerardsans.
> Final solution and step by step tutorial in GitHub.
Introduction to Offline-first, PWAs and Amplify DataStore
Offline-first is an approach to software development, where an application is built to function, with or without an internet connection. Using this approach, data is stored locally on the user’s device and periodically uploaded and replicated into the cloud. Features include:
- Offline ready: application works offline. This usually comes with some limitations regarding feature set and device capabilities. Eg: PWAs require an install step where the user needs to be online to download all the necessary application assets.
- Great user experience: application loads fast and transitions seamlessly from online to offline improving user retention. The user is in control regarding app updates, data synchronisation, data conflict resolution and connectivity. Eg: App shell; user is informed about connectivity changes.
- Native-like features: application behaves similar to a native app. Eg: PWAs are able to run on multiple operating systems as standalone applications and don’t require the user to interact with the browser.
- Reliable storage: user can exit the application at any moment without losing data. Application stores user data securely on-device and synchronises with the cloud transparently when possible.
Progressive Web Apps
Progressive Web Apps combine the benefits from native apps, while being able to run on more platforms, leveraging browser technologies. PWA features allow Web apps to close the gap with native applications while creating similar user experiences. PWA features:
- Works offline via service worker.
- Great performance via app shell and pre-cached assets.
- Access to device APIs via Web APIs.
- Supports push notifications via service worker.
- Works in Web, Mobile and Desktop via Web app manifest.
The most important requirements for PWA are: security, must be served via HTTPS, the service worker and the Web app manifest. We will incorporate all of these to Chatty.
Amplify DataStore
Amplify DataStore, is an on device persistent repository for interacting with local data, and able to automatically synchronise via GraphQL. Using Amplify DataStore, you can provide a great offline-first experience for your users, while using a simple programming model.
GraphQL knowledge is beneficial when using Amplify DataStore to understand its inner workings but not necessary to use DataStore APIs.
Amplify takes care of all the heavy lifting regarding hosting your data source in the cloud using Amazon DynamoDB; and scaling real-time messaging, synchronising data and running conflict resolution with AWS AppSync and GraphQL.
Amplify DataStore uses your GraphQL schema to generate data models. These will help keep your client code consistent with the type and fields defined in your GraphQL schema; and reduce type checks and data related run-time errors while enforcing GraphQL type safety.
Amplify DataStore behaves differently when in offline or online modes:
- In offline mode, we use the DataStore API to manage the data on the device via the Storage Engine using GraphQL. The local Data Store being used will vary depending on the user device. This can either be IndexedDB in the browser or SQL Lite in Android and iOS.
- In online mode, Amplify DataStore, will synchronise with the cloud using the Sync Engine via GraphQL to automatically synchronise with your GraphQL API. At the query level, this is implemented as an AWS AppSync resolver accessing Amazon DynamoDB, AWS Lambda or Amazon ElasticSearch.
Amplify DataStore supports the following conflict resolution modes during synchronisation:
- Optimistic concurrency, this mode rejects any incoming changes if there’s a mismatch in the tracking version between the incoming change and the item being changed. A further step on the client is required to resolve the conflict.
- Auto merge (default), this mode doesn’t require further steps to resolve conflicts. It accommodates mutations on a field-by-field basis as long as scalar types don’t clash. If they do, they are ignored but all other fields are merged, if possible.
- Custom, this mode uses AWS Lambda to allow more control and complex use cases.
For more details read conflict detection and sync.
Setting up a new project with the Vue CLI
Before moving to the next section, please complete the steps described in “Build your first full-stack serverless app with Vue”. Here you will set up the initial project, familiarise with Amplify CLI and add an authorisation flow so users can register themselves via an automated verification code sent to their email and login.
Creating a new GraphQL API
First, we are going to create the GraphQL API to service Chatty chatroom. To create it, we will use the following command:
amplify add api
Answer the following questions
- Please select from one of the below mentioned services GraphQL
- Provide API name: ChattyAPI
- Choose the default authorization type for the API API key
- Enter a description for the API key: (empty)
- After how many days from now the API key should expire (1–365): 7
It’s important that you don’t miss the following steps to set up conflict detection. If you missed it, you can recover with
amplify api update
- Do you want to configure advanced settings for the GraphQL API Yes, I want to make some additional changes.
- Configure additional auth types? No
- Configure conflict detection? Yes
- Select the default resolution strategy Auto Merge
- Do you want to override default per model settings? No
- Do you have an annotated GraphQL schema? No
- Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
- Do you want to edit the schema now? Yes
When prompted, replace the default schema with the following:
type Chatty @model {
id: ID!
user: String!
message: String!
createdAt: AWSDateTime
}
By adding@model
to the Chatty
type we are telling the Amplify CLI to create the resolvers to support queries, mutations and subscriptions in Amazon DynamoDB. Besides the regular CRUD operations, we are also getting some more advanced features like pagination, filtering and real-time synchronisation that we are going to use later.
Pushing your GraphQL API to the cloud
Let’s run the push command to create the GraphQL API:
amplify push
- Are you sure you want to continue? Yes
- Do you want to generate code for your newly created GraphQL API Yes
- Choose the code generation language target javascript
- Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js
- Do you want to generate/update all possible GraphQL operations — queries, mutations and subscriptions Yes
- Enter maximum statement depth [increase from default if your schema is deeply nested] 2
Write down your GraphQL endpoint and API KEY.
This is the resulting architecture with our new GraphQL API managed by AWS AppSync and Amazon DynamoDB:
Run the command below to access the AWS AppSync console.
amplify console api
- Please select from one of the below mentioned services GraphQL
Amplify DataStore setup
Run the command below to install the necessary dependencies:
npm install --save @aws-amplify/core @aws-amplify/datastore
Data model generation
Generate the data models to manage our messages for our ChattyAPI.
amplify codegen models
Important: DO NOT forget to regenerate your models every time you introduce a change in your schema.
After running this command, AWS Amplify CLI has generated the necessary data models in a new folder. The files in this folder hold your data model classes and schema.
<amplify-app>
|_ src
|_ models
Creating a message
Now that the GraphQL API and data models are created, we can begin using the DataStore API. The first thing we’ll do is create a new message passing the generated data model to save
.
Using data models allow us to be always compliant with the GraphQL schema and prevent errors right in the client.
import { DataStore } from "@aws-amplify/datastore";
import { Chatty } from "./models";await DataStore.save(new Chatty({
user: "amplify-user",
message: "Hi everyone!",
createdAt: new Date().toISOString()
}))
This will create a record locally in your device and synchronise it in the background using the underlying GraphQL API when in online mode.
Querying data
Let’s see how we can query the data using Amplify DataStore. In order to query our data model we will use a query and a predicate to indicate that we want all records.
import { DataStore, Predicates } from "@aws-amplify/datastore";
import { Chatty } from "./models";const messages = await DataStore.query(Chatty, Predicates.ALL);
This will return an array of messages that we can display in our UI. Predicates also support filters for common types like Strings
, Numbers
and Lists
.
Find all supported filters at query with Predicates.
Creating the UI with Vue
Now, let’s look at how we can create the UI to create and display messages for Chatty chatroom.
<template>
<div v-for="message of sorted" :key="message.id">
<div>{{ message.user }} - {{ moment(message.createdAt).format('YYYY-MM-DD HH:mm:ss')}})</div>
<div>{{ message.message }}</div>
</div>
</template>
<script>
import { DataStore, Predicates } from "@aws-amplify/datastore";
import { Chatty } from "./models";
import moment from "moment";export default {
name: 'app',
data() {
return {
user: {},
messages: [],
}
},
computed: {
sorted() {
return [...this.messages].sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
}
},
created() {
// authentication state managament
onAuthUIStateChange((state, user) => {
// set current user and load data after login
switch (state) {
case AuthState.SignedIn: {
this.user = user;
this.loadMessages();
break;
}
}
});
},
methods: {
moment: () => moment(),
loadMessages() {
DataStore.query(Chatty, Predicates.ALL).then(messages => {
this.messages = messages;
});
},
}
}
</script>
After the user signs in, we call loadMessages
. This fetches all the messages in our local store. We are sorting the list with a computed method sorted
as we want our messages to show the most recent at the top. We are also using moment
to format our time variables.
Creating a message
Now, let’s look at how we create new messages.
<template>
<form v-on:submit.prevent>
<input v-model="form.message" placeholder="Enter your message..." />
<button @click="sendMessage">Send</button>
</form>
</template>
<script>
export default {
data() {
return {
form: {},
};
},
methods: {
sendMessage() {
const { message } = this.form
if (!message) return;
DataStore.save(new Chatty({
user: this.user.username,
message: message,
createdAt: new Date().toISOString()
})).then(() => {
this.form = { message: '' };
this.loadMessages();
}).catch(e => {
console.log('error creating message...', e);
});
},
}
}
</script>
We use a form to read the message from the user and call sendMessage
with save
passing the user and current date along with the message. Once the message has been created we reset the input field and reload all messages. Note how we use Amplify DataStore as our source of truth in this implementation.
Deleting all messages
One of the main advantages of working using Amplify DataStore is being able to run a series of GraphQL mutations without having to use a series of individual operations. See below how we can use delete together with a predicate to remove all messages.
DataStore.delete(Chatty, Predicates.ALL).then(() => {
console.log('messages deleted!');
});
When using Amplify DataStore records are never removed, but marked for removal. Records will then be automatically removed as defined in their TTL attribute. At the time of writing, this is set to 30 days.
Real-time with GraphQL subscriptions
Next, let’s see how we can create a GraphQL subscription. To do so, we will listen for changes of data in our API, and update the state whenever a new piece of data comes through. When the component is destroyed, we will unsubscribe
to avoid memory leaks.
As we are using Amplify DataStore, as our source of truth, we don’t need another copy of it at the app level so we just need to load the latest state with
loadMessages
.
<script>
export default {
data() {
return {
subscription: undefined;
};
},
created() {
//Subscribe to changes
this.subscription = DataStore.observe(Chatty).subscribe(msg => {
console.log(msg.model, msg.opType, msg.element);
this.loadMessages();
});
},
destroyed() {
if (!this.subscription) return;
this.subscription.unsubscribe();
},
}
</script>
At this point, the chatroom is fully working including authentication flow and data synchronisation in real-time.
On the other hand, we haven’t made much progress in making it work offline or having native-like features. In order to tick these boxes, we are going to make our app a PWA.
Making Chatty a PWA
The PWA CLI plugin can help us making Chatty a PWA by running a single command:
vue add @vue/pwa
This will make few changes to the project and add a few new assets necessary to run the app as a PWA. The most important change happens in src/main.js
where we are now registering a service worker that will allow Chatty to work offline.
Using the default settings, the service worker is configured to pre-cache all the assets resulting from the build (js
, css
and public
folder). Using this setup, the browser can access the cache to load the app while offline. See the diagram below:
Learn more about service workers at using service workers.
Run the build command to see changes:
yarn build
In your dist
folder you should find the following:
└── dist
├── css
│ └── app.<version>.css
├── img/icons
│ ├── android-chrome-<size>.png
│ └── ...
├── js
│ ├── app.<version>.png
│ └── ...
├── favicon.ico
├── index.html
├── manifest.json
├── precache-manifest.<version>.json
├── robots.txt
└── service-worker.js
The index.html
has been modified to include the Web app manifest manifest.json
along with some meta tags and entries to support different devices and platforms. You can further customise these settings. The most important ismanifest.json/display
set to standalone
. This setting will give the app a look and feel like a native application for Desktop and Mobile. Learn about all the options available in Web app manifest.
Another important file is service-worker.js
. This contains the implementation for the service worker which determines the behaviour of the app while offline. As part of it, precache-manifest.<version>.json
includes all files to be installed and pre-cached as part of the app. This will make the app load almost instantly as it won’t use the network. These files were created by the PWA CLI plugin and include all the assets from your build.
Test the app running the following commands on a new terminal window:
cd dist
python -m SimpleHTTPServer 8887 // open localhost:8887
Remember that in order to test the app you need to install the service worker first. This requires at least loading the app once.
To test the app while offline you can use the Developer Tools or switch off your internet connection. See below a screen capture after refreshing the page while offline:
As you can see in the screenshot above using Chrome, in the Network tab, the files are served by the service worker while offline (in green). Unfortunately the default setup won’t include any custom assets like our SVG logo in the header (in red) or external fonts.
Adding a PWA custom configuration
Let’s fix the issue with our logo by using a custom configuration. Create vue.config.js
with the following content at the root of your project:
// vue.config.js
const manifest = require('./public/manifest.json')module.exports = {
pwa: {
name: manifest.short_name,
themeColor: manifest.theme_color,
msTileColor: manifest.background_color,
appleMobileWebAppCapable: 'yes',
appleMobileWebAppStatusBarStyle: 'black',
workboxPluginMode: 'InjectManifest',
workboxOptions: {
swSrc: 'src/service-worker.js',
}
}
}
This file controls the PWA CLI plugin and deal with settings in the Web app manifest and UX for different devices.
The custom configuration uses workbox in injectManifest
mode and points to the service worker implementation we want to use. Find all configuration options at the official website. Now create src/service-worker.js
with the following code
// src/service-worker.js
workbox.core.setCacheNameDetails({ prefix: 'amplify-datastore' })workbox.core.skipWaiting()
workbox.core.clientsClaim()const cacheFiles = [{
"revision": "e653ab4d124bf16b5232",
"url": "https://aws-amplify.github.io/img/amplify.svg"
}]self.__precacheManifest = cacheFiles.concat(self.__precacheManifest || [])
workbox.precaching.precacheAndRoute(self.__precacheManifest, {})
For our service worker, we are setting a prefix for our cache to easily find our files while using the Developer Tools. We are also setting the behaviour for new service worker versions. These will be applied immediately replacing any existing ones with skipWaiting
and clientsClaim
as explained here. To fix our SVG issue, we are adding the url to the SVG file, to the pre-cached files defined in the Web app manifest.
Improving UX while offline
We are almost done. Our app can survive offline reloads, can store messages while offline and can synchronise when back online thanks to Amplify DataStore.
The only remaining issue now is improving the user experience as the user doesn’t know when the app is operating online or offline. This is not a great UX, as sometimes the user might inadvertently go offline and be confused as for not receiving any feedback or explanation from the app. We can fix this by using the code below:
// <div v-if="offline">You are offline.</div>
// <div v-bind:class="{ offline: offline }">// App.vue
import { Hub } from 'aws-amplify';export default {
data() {
return { offline: undefined };
},
created() {
this.listener = Hub.listen('datastore', {payload:{event}} => {
if (event === 'networkStatus') {
this.offline = !data.active;
}
})
}
}
We are using the Amplify Hub to detect network changes and use it to set the offline
flag. Once in place, we can use it to display messages or style the UI accordingly as shown at the beginning of the code above. We are now ready to publish Chatty to the cloud and test it using different devices.
Publishing your app via the AWS Amplify Console
The first thing you need to do is create a new repo for this project. Once you’ve created the repo, copy the URL for the project to the clipboard and initialise git in your local project:
git init
git remote add origin repo@repoofyourchoice.com:username/project-name.git
git add .git commit -m 'initial commit'git push origin master
Next visit the AWS Amplify Console in your AWS account. Click Get Started to create a new deployment. Next, authorise your repository provider as the repository service. Next, choose the new repository and branch for the project you just created and click Next. In the next screen, create a new role and use this role to allow the AWS Amplify Console to deploy these resources and click Next. Finally, click Save and Deploy to deploy your application!
This will generate a public url that uses HTTPS, which is a requirement for PWAs. Congratulations! You can now test it in your browser, Desktop and Mobile.
Installing the Chatty app in the Desktop and Mobile
Due to the inclusion of the Web app manifest in our index.html
page, you can now install the Chatty app in the Desktop and Mobile devices in Windows, Mac and Linux. The way it works, changes slightly depending the device and operating system you are using. Try it out following the steps in Add to Home screen.
Cleaning up cloud services
If at any time, you would like to delete a service from your project and your AWS Account, you can do this by running:
amplify delete
Conclusion
Congratulations! You successfully built your first offline-first app using Vue and Amplify. You created a chatroom PWA that stores data on the device while offline and when online synchronises in real-time with other devices using Amplify DataStore, AWS AppSync, GraphQL and Amazon DynamoDB. Thanks for following this tutorial.
If you prefer, you can follow the instructions in this video to build the chatroom app.
Thanks for reading!
Have you got any questions about this tutorial or AWS Amplify? Feel free to reach me anytime at @gerardsans.
My Name is Gerard Sans. I am a Developer Advocate at AWS Mobile working with AWS Amplify and AWS AppSync teams.
GraphQL is an open source data query and manipulation language for APIs, and a runtime for fulfilling queries with existing data.