Stop Exposing API Keys: A BFF Pattern for AI Streaming Apps
Essential for Full-Stack Frontend Engineers: Building a Secure AI Streaming Application with BFF Architecture
Abstract
When building AI applications, directly calling the LLM API from the frontend faces three major problems: API Key exposure, complex SSE streaming data parsing, and cross-origin restrictions. This article builds a BFF (Backend For Frontend) architecture from scratch using Vue 3 + Vite + Express, securely hiding the API Key on the server side, handling streaming data in the Node layer, and allowing the frontend to get clean data with a single line of fetch. After reading, you will understand why a BFF is needed, how the Vite proxy solves cross-origin issues, the essential differences between browser and Node processes, and how to handle the complete chain from the frontend to the AI interface by yourself.
1. The Starting Point: Three Fatal Flaws of Frontend Directly Connecting to LLMs
When building an AI chat application, if the frontend directly calls the DeepSeek (or OpenAI) API, it immediately hits three walls:
Fatal 1: API Key Exposure
// Frontend code — anyone can see it by pressing F12
fetch('https://api.deepseek.com/v1/chat/completions', {
headers: { 'Authorization': 'Bearer sk-xxxxxxxx' }
})
The Key is written in the JS, and a user can right-click to view the source and take it. It's only a matter of time before someone steals it and your API bill explodes.
Fatal 2: Complex SSE Streaming Data Parsing
The LLM returns a raw SSE (Server-Sent Events) stream:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
The frontend needs ReadableStream → getReader() → manual decoding → splitting by line → parsing the data: prefix → extracting JSON → concatenating strings. The code is messy and long, and it has to be rewritten for every client (Web, iOS, Android).
Fatal 3: Cross-Origin Blocking
The frontend runs on localhost:5173 (Vite), and the BFF runs on localhost:3000 (Express). Different ports mean cross-origin, and the browser directly blocks the request, preventing it from being sent at all.
2. The Solution: BFF Architecture
BFF (Backend For Frontend) is a Node middle layer placed between the frontend and backend, specifically serving the frontend.
Without BFF: Frontend ──→ LLM (Key exposed / complex stream parsing)
With BFF: Frontend ──→ Node BFF ──→ LLM (Key hidden in BFF / stream processed in Node layer)
This BFF is written by the frontend engineer themselves, without waiting for backend colleagues. With a BFF:
| Problem | How it's solved |
|---|---|
| Key Security | Key stored in .env file, only on the Node server, never seen by the frontend |
| SSE Parsing | Node layer handles the dirty work, frontend only gets the final result |
| Cross-Origin | Vite proxy forwards requests; communication between Node processes is not subject to browser same-origin policy |
This is the core capability of a "Full-Stack Frontend Engineer": handling the complete chain from the browser UI to the BFF to the AI interface by yourself.
3. Project Structure Overview
stream-bff/
├── index.html ← Browser entry point
├── package.json ← Project dependency description
├── vite.config.js ← Vite build config + cross-origin proxy
├── server.mjs ← BFF core! Express server
├── .env ← API Key hiding place (do not commit to Git)
├── src/
│ ├── main.js ← Vue application startup
│ └── App.vue ← Root component (UI + sending requests)
Division of labor among three processes:
| Process | Startup Method | Port | Responsibility |
|---|---|---|---|
| Vite | npm run dev |
5173 | Compile Vue, serve page files, proxy API requests |
| Express | node server.mjs |
3000 | Hide Key, call LLM, process streaming data, return to frontend |
| Browser | Opened by user | — | Render page, execute Vue code, display AI reply |
4. Browser Layer: How the Page Loads
File relay chain:
index.html
│ <div id="app"></div> ← Vue mount point, initially empty
│ <script src="/src/main.js"> ← Load JS entry
▼
main.js
│ import { createApp } from 'vue'
│ import App from './App.vue'
│ createApp(App).mount('#app') ← Insert App component into div#app
▼
App.vue
│ <script setup> — sends fetch request
│ <template> — UI structure
│ <style> — styles
▼
Page rendering complete, user sees the interface
Advantage of Vue Single-File Components (.vue): Template, logic, and styles are encapsulated in one file, without being scattered across multiple files. Vite uses the @vitejs/plugin-vue plugin to compile .vue into JavaScript that the browser can execute.
5. Solving Cross-Origin: Vite Proxy
What is cross-origin? The browser's same-origin policy: if protocol, domain, or port is different, the request is blocked.
Vite :5173
Express :3000 → Different ports → Cross-origin!
Solution — Configure proxy in vite.config.js:
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': { // Intercept all requests starting with /api
target: 'http://localhost:3000', // Forward to Express
secure: false, // Don't verify HTTPS in dev environment
rewrite: (path) => path.replace(/^\/api/, '') // /api/stream → /stream
}
}
}
})
The complete journey of a request:
Browser fetch('/api/stream?prompt=你好')
│ Browser auto-completes: http://localhost:5173/api/stream?prompt=你好
│ Same origin! (5173→5173) No cross-origin!
▼
Vite :5173 receives the request
│ /api prefix matches proxy rule
│ rewrite: /api/stream → /stream
│ target: Forward to http://localhost:3000/stream
│ This step is Node↔Node, doesn't go through the browser, no cross-origin check!
▼
Express :3000 receives GET /stream?prompt=你好
Why doesn't the proxy trigger cross-origin? Cross-origin checks only happen in the browser. Vite and Express are both Node processes; processes communicate directly, and the browser has no say. It's like needing to go through customs when leaving the country (browser), but colleagues inside the company can transfer files without it.
What is the role of /api? An agreed-upon prefix marker. Starts with /api → this is a backend API request, Vite forwards it for you. Not /api → frontend static resource, Vite handles it itself.
6. BFF Layer: server.mjs Explained
// ① Load environment variables
import * as dotenv from 'dotenv'
dotenv.config({ path: ['.env.local', '.env'] }) // Read .env, Key goes into process.env
// ② Import Express framework
import express from 'express'
// ③ Instantiate server
const app = express() // Create app instance
const port = 3000 // Define port
// ④ Health check route
app.get('/', (req, res) => {
res.send('Hello World!') // Test if service is running
})
// ⑤ Core business route — frontend calls this
app.get('/stream', async (req, res) => {
const { prompt } = req.query // Extract user input from URL: ?prompt=你好
const endpoint = 'https://api.deepseek.com/v1/chat/completions'
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}` // Key securely read from .env
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
stream: true, // Enable streaming return
messages: [{ role: 'user', content: prompt }]
})
})
// response.body is a ReadableStream — a pipe interface, still needs reading and processing
} catch (err) {
// Error handling
}
})
// ⑥ Start server
app.listen(port, () => {
console.log(`Server started on port ${port}`)
})
Key concepts:
What is a port? A port is a "room number" assigned by the operating system to a process. app.listen(3000) applies to the OS for room number 3000; afterwards, all network requests sent to localhost:3000 are automatically forwarded by the OS to this Express process.
What is a route? A mapping between request paths and functions:
GET / → returns "Hello World!"
GET /stream → calls DeepSeek and returns the result
GET /abc → not found, 404
What are req and res? Two objects automatically passed in by Express, packaged for you every time a request comes in:
| Object | Direction | Role | Common properties/methods |
|---|---|---|---|
req (Request) |
Frontend → Backend | All request info | .query (URL params), .method (GET/POST), .headers |
res (Response) |
Backend → Frontend | Reply toolkit | .send(), .json(), .status() |
Analogy: req is the earpiece (hearing what the other party says), res is the microphone (you reply through it).
7. Frontend Layer: How App.vue Requests the BFF
<script setup>
fetch('/api/stream?prompt=hello') // Relative path, auto-completed to 5173, no cross-origin
.then(res => res.json()) // Read response body, parse JSON
.then(data => console.log(data)) // Get the real data
</script>
Why are there two .then()?
fetch()
→ Returns a Response object (envelope: status code + headers + body stream)
.then(res => res.json()) ← Open the envelope, read the body
→ Returns the parsed JS object
.then(data => console.log()) ← Get the content
res is the delivery package, res.json() opens it, data is the thing inside.
What is ?prompt=hello? ? is the separator between path and parameters — before it is where to go, after it is what data to bring. Express automatically parses ?prompt=hello into req.query = { prompt: "hello" }.
8. Complete Data Flow (Everything that happens after the user clicks "Send")
① User inputs "你好", clicks submit
│
② App.vue executes fetch('/api/stream?prompt=你好')
→ Browser completes to http://localhost:5173/api/stream?prompt=你好
→ Same origin! No cross-origin!
│
③ Vite :5173 receives the request
→ /api prefix matches proxy rule
→ rewrite: /api/stream → /stream
→ Node internally forwards to Express :3000/stream?prompt=你好
│
④ Express :3000 receives GET /stream
→ Route matches app.get('/stream', fn)
→ req.query.prompt → "你好"
→ fetch DeepSeek, with API Key from .env
│
⑤ DeepSeek returns SSE streaming data
→ BFF parses the stream, extracts content
→ res.json() returns to Vite
│
⑥ Vite returns the response as-is to the browser
│
⑦ App.vue gets the data → renders to the page
→ User sees the AI reply
9. Core Knowledge Points Connected
| Concept | One-sentence explanation |
|---|---|
| BFF | A Node middle layer customized for the frontend, hides Keys, processes streams, returns clean data |
| SSE | Server unidirectional push technology, LLMs use this to stream AI replies |
| Port | The "room number" of a process in the operating system |
| Instantiation | express() = turning a blueprint into a concrete object that can work |
| Route | The mapping between URL paths and functions |
| Cross-Origin | Browser security policy, blocks if protocol/domain/port differs |
| Vite Proxy | Bypasses cross-origin during development, Node↔Node communication is not subject to checks |
req |
Request object, all info sent from the frontend (earpiece) |
res |
Response object, the toolkit for the backend to reply to the frontend (microphone) |
.env |
Stores sensitive configuration (API Key), not committed to Git |
res.json() |
Converts a JS object into a JSON string and returns it to the frontend |
| Single-File Component | Vue's .vue file, encapsulates template + logic + styles together |
10. Summary
The core competitive advantage of a Full-Stack Frontend Engineer is independently solving problems across the entire chain. The value of this BFF architecture lies in:
- Security — Key is hidden in
.env, users never see it - Simplicity — Frontend only calls
fetch, the dirty work of SSE parsing is left to Node - Independence — Change the interface whenever you want, no waiting for backend colleagues to schedule it
- Reusability — Write the BFF once, Web/iOS/Android can all call it
The code in this article currently completes the request chain setup from the browser to the BFF to the LLM. The next step is to read the ReadableStream returned by DeepSeek in server.mjs, reading and forwarding it to the frontend in real-time, achieving true streaming conversation. This will be detailed in a subsequent article.
Keywords: BFF, SSE, Vite, Express, Cross-Origin, Full-Stack Frontend, Streaming Output, AI Application Architecture