This episode covers external API integration in Nuxt: calling third-party services from the server, protecting requests with auth headers, storing secrets safely via runtime config, and applying rate limiting and resilient error handling.

Real applications rarely work alone — they call payment gateways, email services, or weather APIs. Episode 13 covers how to integrate external APIs correctly in Nuxt: where to call them from, how requests are secured, where secrets are stored, and how to handle failures.
The most important rule of this episode: external API calls that need secrets must happen on the server, not in the browser. Client code can be read by anyone, so API keys and tokens may only live on the server side.
The most appropriate place for external calls is server/api. Let's create an endpoint that connects to a payment gateway:
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const hasil = await $fetch("https://api.payment.dev/v1/charge", {
method: "POST",
body,
headers: {
Authorization: `Bearer ${useRuntimeConfig().apiKeyPembayaran}`,
},
})
return hasil
})$fetch in a server route carries the Authorization header taken from runtime config. The browser never sees this API key because the entire call happens on the server.
Often the issue isn't security but CORS — the browser blocks cross-origin calls. Using a server route as a proxy solves two problems at once: CORS and secret protection.
Every request to an external API that requires authorization must carry a token. Store the token in runtime config and never hardcode it:
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const res = await $fetch("https://api.eksternal.dev/v2/produk", {
headers: {
"X-Api-Key": config.apiKeyEksternal,
},
})
return res
})config.apiKeyEksternal is read from NUXT_API_KEY_EKSTERNAL in the environment. Custom headers like X-Api-Key are a common pattern used by many third-party APIs.
Some public APIs indeed don't need a token, but you should still restrict who can call them. If an API is paid or has quotas, don't let anyone call it without control.
Classifying data is crucial. What may go in runtimeConfig.public is only public data like base URLs. API keys, tokens, and passwords only go in the private runtimeConfig:
export default defineNuxtConfig({
runtimeConfig: {
stripeSecret: process.env.STRIPE_SECRET_KEY,
public: {
stripePublishable: process.env.STRIPE_PUBLISHABLE_KEY,
},
},
})stripeSecret is never sent to the client, while stripePublishable is safe to use in the frontend — for example to display a card form.
A common mistake: console.log(config.stripeSecret) while debugging, then leaking it into server logs that others can read. Make a simple rule: never display secrets in logs, error messages, or API responses.
Rate limiting limits the number of requests per user. Nitro provides storage well suited to this pattern:
export default defineEventHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true })
const kunci = `rate:${ip}`
const storage = useStorage("cache")
const jumlah = (await storage.getItem(kunci)) || 0
if (jumlah >= 100) {
throw createError({
statusCode: 429,
message: "Terlalu banyak request",
})
}
await storage.setItem(kunci, jumlah + 1, { ttl: 3600 })
return { ok: true }
})The pattern above counts requests per IP using Nitro storage with a one-hour TTL. Status 429 is returned when the limit is exceeded — protecting the endpoint from brute force and abuse.
Adjust limits to the nature of the endpoint: public pages can be more lenient, login endpoints should be strict. Combine with exponential backoff on the client side for a still-good experience.
Third-party APIs can fail, be slow, or change formats. Server routes must catch errors and return clear messages:
export default defineEventHandler(async () => {
try {
const res = await $fetch("https://api.eksternal.dev/v1/status")
return res
} catch (e) {
throw createError({
statusCode: 503,
message: "Layanan eksternal sedang tidak tersedia",
})
}
})createError({ statusCode: 503, ... }) communicates the failure to the client with the right status. Never leak internal details like stack traces to users.
For transient failures, add retries with increasing delays:
for (let i = 1; i <= 3; i++) {
try {
return await $fetch("https://api.eksternal.dev/v1/resi")
} catch (e) {
if (i === 3) throw e
await new Promise((r) => setTimeout(r, i * 500))
}
}The retry pattern above tries up to three times with graduated delays. Combine it with a timeout so hanging requests don't consume server resources.
Episode 13 secures your external integrations: third-party API calls happen from the server, requests are protected with auth headers, secrets are stored only on the server side via runtime config, rate limiting protects endpoints from abuse, and error handling makes the system resilient to failures.
Key takeaways:
runtimeConfig, never in public.In the next episode, episode 14, we will discuss caching and performance — caching data with Nitro and browser cache, static generation and ISR, page load optimization and asset delivery, and monitoring build performance. Your store will start running fast.