Welcome back to this series where we are learning how to integrate AI products into web applications. Intro & Setup Your First AI Prompt Streaming Responses How Does AI Work Prompt Engineering AI-Generated Images Security & Reliability Deploying , we got all the boilerplate work out of the way. Last time In this post, we’ll learn how to integrate OpenAI’s API responses into our Qwik app using . We’ll want to make sure we’re not leaking API keys by executing these HTTP requests from a backend. fetch By the end of this post, we will have a rudimentary, but AI application. working https://www.youtube.com/watch?v=gUgRD0sRoCU&embedable=true Generate OpenAI API Key Before we start building anything, you’ll need to go to and generate an API key to use in your application. platform.openai.com/account/api-keys Make sure to keep a copy of it somewhere because you will only be able to see it once. With your API key, you’ll be able to make authenticated HTTP requests to . So, it’s a good idea to get familiar with the API itself. I’d encourage you to take a brief look through the and become familiar with some concepts. The are particularly good to understand because they have varying capabilities. OpenAI OpenAI Documentation models If you would like to familiarize yourself with the API endpoints, expected payloads, and return values, check out the . It also contains helpful examples. OpenAI API Reference You may notice the package available on NPM called . We will be using this, as it doesn’t quite support some things we’ll want to do, that can. JavaScript openai not fetch Make Your First HTTP Request The application we’re going to build will make an AI-generated text completion based on the user input. For that, we’ll want to work with the (note that the completions endpoint is deprecated). chat endpoint We need to make a request to https://api.openai.com/v1/chat/completions with the header set to , the set to (you’ll need to replace OPENAI_API_KEY with your API key), and the set to a JSON string containing the GPT model to use (we’ll use ) and an array of messages: POST 'Content-Type' 'application/json' 'Authorization' 'Bearer OPENAI_API_KEY' body gpt-3.5-turbo fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer OPENAI_API_KEY' }, body: JSON.stringify({ 'model': 'gpt-3.5-turbo', 'messages': [ { 'role': 'user', 'content': 'Tell me a funny joke' } ] }) }) You can run this right from your browser console, and see the request in the Network tab of your dev tools. The response should be a JSON object with a bunch of properties, but the one we’re most interested in is the . It will be an array of text completions objects. The first one should be an object with a object that has a property with the chat completion. "choices" "message" "content" { "id": "chatcmpl-7q63Hd9pCPxY3H4pW67f1BPSmJs2u", "object": "chat.completion", "created": 1692650675, "model": "gpt-3.5-turbo-0613", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Why don't scientists trust atoms?\n\nBecause they make up everything!" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 12, "completion_tokens": 13, "total_tokens": 25 } } Congrats! Now, you can request a mediocre joke whenever you want. Build the Form The request above is fine, but it’s not quite an application. What we want is something a user can interact with to generate an HTTP request like the one above. fetch For that, we’ll probably want some sort to start with an containing a . Below is the minimum markup we need, and if you want to learn more, consider reading these articles: HTML <form> <textarea> “How to Build HTML Forms Right: Semantics” “How to Build HTML Forms Right: Accessibility” “How to Build Great HTML Form Controls” <form> <label for="prompt">Prompt</label> <textarea id="prompt" name="prompt"></textarea> <button>Tell me</button> </form> We can copy and paste this form right inside our Qwik component’s JSX template. If you’ve worked with JSX in the past, you may be used to replacing the attribute on the with , but Qwik’s compiler actually doesn’t require us to do that, so it’s fine as is. for <label> htmlFor Next, we’ll want to replace the default form submission behavior. By default, when an HTML form is submitted, the browser will create an HTTP request by loading the URL provided in the form’s attribute. action If none is provided, it will use the current URL. We want to avoid this page load and use JavaScript instead. If you’ve done this before, you may be familiar with the method on the interface. As the name suggests, it prevents the default behavior for the event. preventDefault Event There’s a challenge here due to . Unlike other frameworks, Qwik does not download all the JavaScript logic for the application upon first-page load. Instead, it has a very thin client that intercepts user interactions and downloads the JavaScript event handlers on-demand. how Qwik deals with event handlers This asynchronous nature makes Qwik applications much faster to load but introduces the challenge of dealing with event handlers asynchronously. It makes it impossible to prevent the default behavior the same way as synchronous event handlers that are downloaded and parsed before the user interactions. Fortunately, Qwik provides a way to prevent the default behavior by adding to the HTML tag. A very basic form example may look something like this: preventdefault:{eventName} import { component$ } from '@builder.io/qwik'; export default component$(() => { return ( <form preventdefault:submit onSubmit$={(event) => { console.log(event) }} > <!-- form contents --> </form> ) }) Did you notice that little at the end of the handler, there? Keep an eye out for those because they are usually a hint to the developer that Qwik’s compiler is going to do something funny and transform the code. In this case, it’s due to that lazy-loading event handling system I mentioned above. If you plan on working with Qwik more, it’s worth . $ onSubmit$ reading more about that here Incorporate the Fetch Request Now, we have the tools in place to replace the default form submission with the fetch request we created above. What we want to do next is pull the data from the into the body of the fetch request. We can do so with , which expects a form element as an argument and provides an API to access a form control values through the control’s attribute. <textarea> FormData name We can access the form element from the event’s property; use it to create a new object, and use that to get the value by referencing its , “prompt”. target FormData <textarea> name Plug that into the body of the fetch request we wrote above, and you might get something that looks like this: export default component$(() => { return ( <form preventdefault:submit onSubmit$={(event) => { const form = event.target const formData = new FormData(form) const prompt = formData.get('prompt') const body = { 'model': 'gpt-3.5-turbo', 'messages': [{ 'role': 'user', 'content': prompt }] } fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer OPENAI_API_KEY' }, body: JSON.stringify(body) }) }} > <!-- form contents --> </form> ) }) In theory, you should now have a form on your page that, when submitted, sends the value from the textarea to the OpenAI API. Protect Your API Keys Although our HTTP request is working, there’s a glaring issue. Because it’s being constructed on the client side, anyone can open the browser dev tools and inspect the properties of the request. This includes the header containing our API keys. Authorization I’ve blocked out my API token here with a red bar. This would allow someone to steal our API tokens and make requests on our behalf, which could lead to abuse or higher charges on our account. Not good!!! The best way to prevent this is to move this API call to a backend server that we control that would work as a proxy. The frontend can make an unauthenticated request to the backend, and the backend would make the authenticated request to OpenAI and return the response to the frontend. But because users can’t inspect backend processes, they would not be able to see the Authentication header. So, how do we move the fetch request to the backend? I’m so glad you asked! We’ve been mostly focusing on building the frontend with Qwik, the framework, but we also have access to , the full-stack meta-framework with tooling for file-based routing, route middleware, HTTP endpoints, and more. Qwik City Of the various options Qwik City offers for running backend logic, my favorite is . It allows us to create a backend function that can be triggered from the client over HTTP (essentially an endpoint). routeAction$ RPC The logic would follow: Use to create an action. routeAction$() Provide the backend logic as the parameter. Programmatically execute the action’s method. submit() A simplified example could be: import { component$ } from '@builder.io/qwik'; import { routeAction$ } from '@builder.io/qwik-city'; export const useAction = routeAction$((params) => { console.log('action on the server', params) return { o: 'k' } }) export default component$(() => { const action = useAction() return ( <form preventdefault:submit onSubmit$={(event) => { action.submit('data') }} > <!-- form contents --> </form> { JSON.stringify(action) } ) }) I included a at the end of the template because I think you should see what the returned looks like. JSON.stringify(action) ActionStore It contains extra information like whether the action is running, what the submission values were, what the response status is, what the returned value is, and more. This is all very useful data that we get out of the box just by using an action, and it allows us to create more robust applications with less work. Enhance the Experience Qwik City's actions are cool, but they get even better when combined with Qwik’s component: <Form> Under the hood, the component uses a native HTML element, so it will work without JavaScript. When JS is enabled, the component will intercept the form submission and trigger the action in SPA mode, allowing to have a full SPA experience. By replacing the HTML element with Qwik’s component, we no longer have to set up , , or call . We can just pass the action to the ‘s prop, and it’ll take care of the work for us. <form> <Form> preventdefault:submit onSubmit$ action.submit() Form action Additionally, it will work if JavaScript is not available for some reason (we could have done this with the HTML version as well, but it would have been more work). import { component$ } from '@builder.io/qwik'; import { routeAction$, Form } from '@builder.io/qwik-city'; export const useAction = routeAction$(() => { console.log('action on the server') return { o: 'k' } }); export default component$(() => { const action = useAction() return ( <Form action={action}> <!-- form contents --> </Form> ) }) So, that’s an improvement for the developer experience. Let’s also improve the user experience. Within the , we have access to the data which keeps track of whether the request is pending or not. It’s handy information we can use to let the user know when the request is in flight. ActionStore isRunning We can do so by modifying the text of the submit button to say “Tell me” when it’s idle, then “One sec…” while it’s loading. I also like to assign the attribute to match the state. aria-disabled isRunning This will hint to assistive technology that it’s not ready to be clicked (though technically still can be). It can also be targeted with CSS to provide visual styles suggesting it’s not quite ready to be clicked again. <button type="submit" aria-disabled={state.isLoading}> {state.isLoading ? 'One sec...' : 'Tell me'} </button> Show the Results Ok, we’ve done way too much work without actually seeing the results on the page. It’s time to change that. Let’s bring the request we prototyped earlier in the browser into our application. fetch We can copy/paste the code right into the body of our action handler, but to access the user’s input data, we’ll need access to the form data that is submitted. Fortunately, any data passed to the method will be available to the action handler as the first parameter. It will be a serialized object where the keys correspond to the form control names. fetch action.submit() Note that I’ll be using the keyword in the body of the handler, which means I also have to tag the handler as an function. await async import { component$ } from '@builder.io/qwik'; import { routeAction$, Form } from '@builder.io/qwik-city'; export const useAction = routeAction$(async (formData) => { const prompt = formData.prompt // From <textarea name="prompt"> const body = { 'model': 'gpt-3.5-turbo', 'messages': [{ 'role': 'user', 'content': prompt }] } const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer OPENAI_API_KEY' }, body: JSON.stringify(body) }) const data = await response.json() return data.choices[0].message.content }) At the end of the action handler, we also want to return some data for the frontend. The OpenAI response comes back as JSON, but I think we might as well just return the text. If you remember from the response object we saw above, that data is located at . responseBody.choices[0].message.content If we set things up correctly, we should be able to access the action handler’s response in the ‘s property. This means we can conditionally render it somewhere in the template like so: ActionStore value {action.value && ( <p>{action.value}</p> )} Use Environment Variables Alright, we’ve moved the OpenAI request to the backend, protected our API keys from prying eyes, we’re getting a (mediocre joke) response, and displaying it on the frontend. The app is working, but there’s still one more security issue to deal with. It’s generally a bad idea to hardcode API keys into your source code, for a number of reasons: It means you can’t share the repo publicly without exposing your keys. You may run up API usage during development, testing, and staging. Changing API keys requires code changes and re-deploys. You’ll need to regenerate API keys anytime someone leaves the org. A better system is to use . With environment variables, you can provide the API keys only to the systems and users that need access to them. environment variables For example, you can make an environment variable called with the value of your OpenAI key for only the production environment. This way, only developers with direct access to that environment would be able to access it. OPENAI_API_KEY This greatly reduces the likelihood of the API keys leaking, it makes it easier to share your code openly, and because you are limiting access to the keys to the least number of people, you don’t need to replace keys as often because someone left the company. In Node.js, it’s common to set environment variables from the command line ( ) or with the popular package. Then, in your server-side code, you can access environment variables using . ENV_VAR=example npm start dotenv process.env.ENV_VAR Things work slightly differently with Qwik. Qwik can target different JavaScript runtimes (not just Node), and accessing environment variables via is a Node-specific concept. To make things more runtime-agnostic, Qwik provides access to environment variables through a object which is available as the second parameter to the route action handler function. process.env RequestEvent import { routeAction$ } from '@builder.io/qwik-city'; export const useAction = routeAction$((param, requestEvent) => { const envVariableValue = requestEvent.env.get('ENV_VARIABLE_NAME') console.log(envVariableValue) return {} }) So, that’s how we access environment variables, but how do we set them? Unfortunately, for production environments, setting environment variables will differ depending on the platform. For a standard server , you can still set them with the terminal as you would in Node ( ). VPS ENV_VAR=example npm start In development, we can alternatively create a file containing our environment variables, and they will be automatically assigned for us. This is convenient since we spend a lot more time starting the development environment, and it means we can provide the appropriate API keys only to the people who need them. local.env So, after you create a file, you can assign the variable to your API key. local.env OPENAI_API_KEY OPENAI_API_KEY="your-api-key" (You may need to restart your dev server) Then we can access the environment variable through the parameter. With that, we can replace the hard-coded value in our request’s Authorization header with the variable using . RequestEvent fetch Template Literals export const usePromptAction = routeAction$(async (formData, requestEvent) => { const OPENAI_API_KEY = requestEvent.env.get('OPENAI_API_KEY') const prompt = formData.prompt const body = { model: 'gpt-3.5-turbo', messages: [{ role: 'user', content: prompt }] } const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'post', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${OPENAI_API_KEY}`, }, body: JSON.stringify(body) }) const data = await response.json() return data.choices[0].message.content }) For more details on environment variables in Qwik, . see their documentation Recap When a user submits the form, the default behavior is intercepted by Qwik’s optimizer which lazy loads the event handler. The event handler uses JavaScript to create an HTTP request containing the form data to send to the server to be handled by the route’s action. The route’s action handler will have access to the form data in the first parameter and can access environment variables from the second parameter (a object). RequestEvent Inside the route’s action handler, we can construct and send the HTTP request to OpenAI using the data we got from the form and the API keys we pulled from the environment variables. With the OpenAI response, we can prepare the data to send back to the client. The client receives the response from the action and can update the page accordingly. Here’s what my final component looks like, including some Tailwind classes and a slightly different template. import { component$ } from "@builder.io/qwik"; import { routeAction$, Form } from "@builder.io/qwik-city"; export const usePromptAction = routeAction$(async (formData, requestEvent) => { const OPENAI_API_KEY = requestEvent.env.get('OPENAI_API_KEY') const prompt = formData.prompt const body = { model: 'gpt-3.5-turbo', messages: [{ role: 'user', content: prompt }] } const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'post', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${OPENAI_API_KEY}`, }, body: JSON.stringify(body) }) const data = await response.json() return data.choices[0].message.content }) export default component$(() => { const action = usePromptAction() return ( <main class="max-w-4xl mx-auto p-4"> <h1 class="text-4xl">Hi 👋</h1> <Form action={action} class="grid gap-4"> <div> <label for="prompt">Prompt</label> <textarea name="prompt" id="prompt"> Tell me a joke </textarea> </div> <div> <button type="submit" aria-disabled={action.isRunning}> {action.isRunning ? 'One sec...' : 'Tell me'} </button> </div> </Form> {action.value && ( <article class="mt-4 border border-2 rounded-lg p-4 bg-[canvas]"> <p>{action.value}</p> </article> )} </main> ); }); Conclusion All right! We’ve gone from a script that uses AI to get mediocre jokes to a full-blown application that securely makes HTTP requests to a backend that uses AI to get mediocre jokes and sends them back to the frontend to put those mediocre jokes on a page. You should feel pretty good about yourself. But not too good, because there’s still room to improve. In our application, we are sending a request and getting an AI response, but we are waiting for the entirety of the body of that response to be generated before showing it to the users. And these AI responses can take a while to complete. If you’ve used AI chat tools in the past, you may be familiar with the experience where it looks like it’s typing the responses to you, one word at a time, as they’re being generated. This doesn’t speed up the total request time, but it does get some information back to the user much sooner and feels like a faster experience. In the next post, we’ll learn how to build that same feature using HTTP streams, which are fascinating and powerful but also can be kind of confusing. So, I’m going to dedicate an entire post just to that. I hope you’re enjoying this series and plan to stick around. In the meantime, have fun generating some mediocre jokes. Intro & Setup Your First AI Prompt Streaming Responses How Does AI Work Prompt Engineering AI-Generated Images Security & Reliability Deploying Thank you so much for reading. If you liked this article, and want to support me, the best ways to do so are to , , and . share it sign up for my newsletter follow me on Twitter First published . here