Build and deploy a GPT-4 powered web app with Vue 3 + AWS Amplify

Tristrum Tuttle
12 min readJan 29, 2024

I recently built an MVP web application that summarizes news articles for teachers. When I build an MVP for a new project, I have a few technical interests:

  1. Practice new programming frameworks: I have a lot of experience building web apps in Ruby on Rails, so these side projects are a great way for me to experiment with other frameworks like Vue 3, React, and Django. For this project, I decided to use Vue 3 and TypeScript.
  2. Solve one problem: My primary interest is building a single feature MVP quickly. This means using templates and boilerplate wherever possible, relying on open source libraries, and importing a design system. For this project, for example, I mostly used imported PrimeVue components for simple buttons and text fields.
  3. Deploy quickly and cheaply: For an MVP project, I don’t want to spend a lot of time building a deployment pipeline from scratch, or worrying about how my web app will scale to 1 million users. With Heroku eliminating its free tier, I have started using AWS Amplify for side projects since its simple to set up and provides easy commands for scaffolding more complicated infrastructure via AWS services like Lambda, Cognito, DynamoDB and more. AWS also helps me stay under budget, especially when I am building a serverless web application.
  4. Minimum viable security: If just one bad actor finds your hardcoded API keys or unprotected API route and blows through your AWS / OpenAI credits, it can turn a small and fun project into a big hassle. I used AWS Cognito to add user authorization to my project and some minimal security for my API endpoints.

In this article, I’ll walk through the steps I took to create and deploy magicalnews.org!

Build your Vue 3 web application

There are already a few good tutorials online for building a web app in Vue 3. Since we know we will be deploying on AWS Amplify, this Amplify-specific tutorial is convenient and extremely relevant to our use case. I start with npm init vue@3, which lets me install a couple of useful libraries immediately. For this project, I used:

  • Pinia for state management
  • TypeScript for type safety
  • Vue Router for SPA development

I also installed PrimeVue for easy out-of-the-box UI components.

For my web app, I wanted to use GPT to re-write news articles for different grade levels. I created a simple App with a single component, ArticleUpload.vue. I added a simple text box to my app for users to copy-paste in an article, then added a dropdown for users to select a grade level and a button to trigger the AI generation.

// components/ArticleUpload.vue

<script setup lang="ts">
...
</script>
<template>
<div class="upload-container">
<p>Copy/paste a news article!</p>
<div class="paste-text">
<Textarea v-model="article" autoResize rows="5" cols="100" ></Textarea>
</div>
<div class="paste-text">
<Dropdown v-model="gradeLevel" :options="gradeLevels" placeholder="Select grade level" />
</div>
<Button label="Submit" @click="sendArticle" />
<div>
{{ summary }}
</div>
</div>
</template>

To test, I had my app send API requests to OpenAI directly. I prototype with GPT-3.5-turbo since it is faster and cheaper than GPT-4 right now. Before deploying (and before pushing to GitHub!) remember to remove your hardcoded secret key. You can generate secret keys by logging into OpenAI and going to the API keys page.

// components/ArticleUpload.vue

<script setup lang="ts">
import Textarea from 'primevue/textarea';
import Dropdown from 'primevue/dropdown';
import Button from 'primevue/button';
import { ref, onMounted } from 'vue'
const article: Ref<string> = ref('')
const template: Ref<string> = ref('Summarize the following article for a student in ')
const gradeLevels: string[] = ['3rd', '6th', '12th'];
const gradeLevel: Ref<string> = ref('')
const summary: Ref<string> = ref('')

function requestOptions() {
const requestOptionsOutput = {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer OPEN_AI_SECRET_KEY'},
body: JSON.stringify(
{
model: 'gpt-3.5-turbo',
messages: [{"role": "user", "content": template.value + gradeLevel + "grade" + encodeURIComponent(article.value)}]
}
)
}
return requestOptionsOutput;
};

function sendArticle() {
fetch('https://api.openai.com/v1/chat/completions', requestOptions())
.then(response => response.json())
.then(data => processData(data))
};

function processData(data) {
summary.value = data.choices[0].message.content;
};
</script>
<template>
...
</template>

Once you are done testing, if you move over to GPT-4 from GPT-3.5-turbo, you may see issues due to differences in request size limits. The GPT-4 model is also considerably slower in my experience, so I kept testing with GPT-3.5-turbo until the very end of the project.

Add User Authentication

Since we don’t want just anyone accessing our GPT queries freely (those can get expensive!) it’s smart to set up some user authentication. Luckily, AWS Amplify makes this easy. If this is your first time using AWS Amplify, you will need to set up the Amplify CLI and create an IAM user. Next, after initializing the Amplify app with amplify init and following the instructions to install and set up Amplify in your Vue 3 app, you can jump to the “Add Authentication” section of the tutorial. With a quick amplify add auth and amplify push your authentication will be ready to use.

Behind the scenes, AWS is setting up a few AWS services for you:

  • An app in AWS Amplify hosting with backend environment (default named “staging”)
  • A “User Pool” and an “Identity Pool” in AWS Cognito

You can view and interact with these service instances via the CLI or AWS Management Console the same way you would view and interact with any other AWS service outside of Amplify.

After adding the Auth via the CLI, we can use Amplify’s pre-built Authenticator component to quickly add a login to our page. Make sure to import Amplify in your main js/ts script.

// main.ts
...
import { Amplify } from 'aws-amplify';
import awsExports from './aws-exports';
Amplify.configure(awsExports);
...
const app = createApp(App)
// App.vue

<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
import ArticleUpload from './components/ArticleUpload.vue';
import { Authenticator } from '@aws-amplify/ui-vue';
import '@aws-amplify/ui-vue/styles.css';
// ... other code below
</script>
<template>
<img src='./assets/magical_news.png' alt="Magical News" width="500" />
<authenticator>
<template v-slot="{ user, signOut }">
<h1>Hello {{ user.username }}!</h1>
<button @click="signOut">Sign Out</button>
<ArticleUpload />
</template>
</authenticator>
</template>

Create a Lambda function for querying GPT-4

Rather than query GPT directly in our web app, we can mask our OpenAI credentials and provide a layer of protection by using our own Lambda function hosted on AWS. This way, nobody can find our secret token by looking through the network logs.

I had set up a Lambda function for querying GPT-4 as part of a previous project, so I highly recommend checking out that tutorial and skipping to Step 3: Setting Up AWS

For this project, I followed the same steps to create a simple, lightweight lambda function just for querying the OpenAI endpoint. This Lambda only needs one layer for the openai-python library, and needs the API key and Org key added as environment variables.

import json
import openai
import os
openai.organization = os.getenv("ORG_KEY")
openai.api_key = os.getenv("OPENAI_API_KEY")

def get_ai_response(prompt):
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
temperature=0.3,
max_tokens=1500,
messages=[
{"role": "user", "content": prompt}
]
)
return completion.choices[0].message.content

def lambda_handler(event, context):
if event["body"] != None:
prompt = json.loads(event["body"])["prompt"]
ai_response = get_ai_response(prompt)
return {
'statusCode': 200,
'body': json.dumps(ai_response)
}
return {
'statusCode': 200,
'body': "ok"
}

You can test your Lambda function by configuring an event that mimics a POST request body.

Add an API Gateway Trigger

In my previous project, I use CloudWatch to run my Lambda function at regular intervals. For this project, we want to run this Lambda function from our Vue web app. The Amplify documentation has a tutorial on how to set up Lambda functions for a vanilla JS app using the Amplify CLI. I have had issues following this tutorial previously, so this time I decided to just set it up manually.

To make your Lambda function accessible to your Vue app, you need to add an API Gateway Trigger. This can be added by clicking “Add Trigger” from the Lambda UI and then configuring an HTTP API.

I used an HTTP API instead of the REST API since it seemed simpler to set up, but its functionally equivalent to a REST API for our purposes. For now, you can leave the Security blank or “open” but just temporarily. We will soon secure this endpoint by adding JWT authorization to ensure that only authorized users are able to access our API.

Once your API Gateway trigger is added, you should be able to trigger your Lambda at the generated API endpoint. Mine is https://pjqgz1r63f.execute-api.us-east-1.amazonaws.com/default/ai-gen-api/.

To test that your API is working correctly, I recommend using Postman or Curl to make some POST requests to your API with the expected body and prompt.

curl https://pjqgz1r63f.execute-api.us-east-1.amazonaws.com/default/ai-gen-api            
-H "Content-Type: application/json"
-d '{ "prompt": "who was the most recent president?" }'

You may be able to access your API from Postman or curl, but you likely will get errors trying to swap it out for your local Vue app. This is because Chrome and other browsers do not support localhost for CORS requests. CORS is a security mechanism that allows your browser to check with the server you are making an API request to and ensure the request is at least coming from an expected origin. In AWS API Gateway, you can configure CORS to allow us to allow localhost as an origin, and set some other CORS configurations as well. If you get a custom URL from Route 53, you will need to add it here as well. Navigate to your API in the API Gateway UI, then set your CORS configurations:

Add a JWT Authorizer to your API Gateway Trigger

The final step, before you can remove your hardcoded GPT credentials and use your new Lambda instead, is to add a JWT authorizer.

Since the API is live at a URL, right now a bad actor could discover our unprotected URL and use it however they want, whenever they want. To add a small amount of protection againse misuse, we will give users that have created an account via our web app (and successfully been added to our User Pool) a JWT token with a timeout of 60 minutes. In the worst case, if we discover someone is misusing our application, we can disable their account and they will be unable to access our API again after the token times out. This may prevent us from needing to rotate our API credentials, or at least save us from needing to redeploy the app entirely.

I’m using JWT authorization instead of Lambda authorization because it seems way simpler, and I really just want to confirm that a user has successfully logged into my web app.

To create an authorizer, you need to provide four pieces of information:

  • Name: I used “Cognito”
  • Identity source: This defaults to $request.header.Authorization which is fine.
  • Issuer URL: Navigate to AWS Cognito (directly or through the Amplify web UI) and then to your new User Pool. This Issuer URL is derived from your region (mine is us-east-1) and the User pool ID (mine is us-east-1_AhGOzo5RO).
  • Audience: In your User Pool, navigate to “Integrations” in the submenu then scroll down to “App client list” and select the one that ends in “clientWeb”. The Audience ID is the Client ID of that client.

Click create and continue. Now that you have the JWT Authorization turned on, you should no longer be able to access your API via any method. You will now need to generate a Cognito ID Token to authorize requests from your web app. You can do this easily using the fetchAuthSession function similar to how they do it in the Retrieve Auth Token documentation:

// components/ArticleUpload.vue

import { fetchAuthSession } from 'aws-amplify/auth';
...
function useRequestOptions(prompt: string, token: any) {
const requestOptionsOutput = {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(
{
model: 'gpt-3.5-turbo',
prompt: prompt
}
)
}
return requestOptionsOutput;
};

async function getAccessToken() {
const { accessToken, idToken } = (await fetchAuthSession()).tokens ?? {};
return idToken?.toString();
}

async function sendArticle() {
loadingArticle.value = true
const prompt = assemblePrompt();
const token = await getAccessToken();

fetch('https://pjqgz1r63f.execute-api.us-east-1.amazonaws.com/default/ai-gen-api', useRequestOptions(prompt, token))
.then(response => response.json())
.then(data => processData(data))
.catch(error => {
alert("Something went wrong...");
})
};
...

Viola! You should be able to make authenticated POST requests to your Lambda function without any errors.

Note: Although I am passing the entire prompt to Lambda directly, a more secure alternative here is to hardcode the prompt in the Lambda’s itself and just pass the news article, so that even authorized users can’t use the Lambda for anything other than getting news summaries.

Debugging API Gateway Authentication + Lambda

I spent a lot of time debugging my API and Lambda integration, and wanted to provide some helpful tips:

  1. The output of idToken?.toString represents an encoded JWT token. If you plug it into an external site like https://jwt.io/, you can confirm that the “iss” key matches the Authorizer URL you set, and the “aud” key matches the Audience (Client ID) that you set.
  2. Curl -v (for verbose) can display additional info help identify failures due to authorization issues.
  3. In the API Gateway UI, under Monitoring → Logging, Access logging is disabled by default but you can turn it on and route the logs to CloudWatch, even temporarily, to try and debug Authorization issues.
  4. CloudWatch logs are your best friend! If you are seeing errors accessing your API, even if they look like CORS or pre-flight errors, check your CloudWatch logs and see what is happening under the hood.

Deploying with AWS Amplify

Now that you have configured your web application, set up a user pool and login panel, and connected your Lambda API with JWT authorization, you can deploy your web app.

Returning to the Amplify Vue tutorial, we are now on the “Deploy and Host” step. This is as simple as running amplify add hosting and then running amplify publish.

I generally set up hosting in the Amplify web console and connect my Amplify instance to my GitHub branch directly, so that pushes to main are automatically deployed to my Amplify host.

If my build breaks, it’s generally because I have TypeScript or other linting errors. If you are seeing build failures, make sure to try running npm run build locally and fix any issues. The build logs in the Amplify Management Console UI are also useful.

If you configured CORS earlier, you may also need to go back and add your newly generated live domain to the CORS Access-Origin configuration! I immediately added a custom domain, but new domains look like https://main…..amplifyapp.com/.

Final Thoughts

I really enjoyed building Magical News as a simple, serverless MVP that is easy to run, build and maintain. AWS Amplify provides a great option for setting up an MVP quickly, cheaply (it generally costs pennies per month) while still enabling scale and future usage of the whole AWS suite. The only real drawbacks I have found are cases where the AWS documentation is out-of-date or hard to follow, or when Amplify gets a bit too “magical” behind the scenes.

Try out Magical News today at https://www.magicalnews.org/

--

--