Guides

Which API Key Can Go in the Browser?

Onttle API keys now come in two types: read-only publishable (pk-proj_) and server-only secret (sk-proj_). How to split them in Next.js.

If you've wired an external API into a Next.js app, you've probably hit this. You want to fetch straight from a client component, but the key comes back undefined from process.env. Prefix it with NEXT_PUBLIC_ and the problem goes away. From that moment on, the key is visible to anyone.
Next.js inlines every environment variable that starts with NEXT_PUBLIC_ into the client bundle at build time (Next.js docs).
That scene is why we split API keys into two types when Onttle v1.0 went GA. [확인 필요: 키를 나누기로 한 실제 계기나 고민 한 줄] This post covers how the two keys differ and where each one lives in a Next.js app, with code.
There are two kinds of keys
Since v1.0.0 on October 30, 2026, you pick a type when you create an API key.

Publishable pk-proj_…

Secret sk-proj_…

GET (read)

Yes

Yes

POST, PUT, PATCH, DELETE

No

Yes

File upload

No

Yes

Chat messages, embedding search

No

Yes

Where to use it

Browsers and public sites (with allowed hosts)

Servers only

Two things worth remembering. First, a key's type can't be changed after creation; if you picked the wrong one, issue a new key. Second, the full key is shown only once, right after you create it. Onttle stores only a hash, and the dashboard shows just the prefix, the last four characters (like sk-proj_abcd…wxyz), and when each key was last used (Authentication docs).

Start by splitting your environment variables
# .env.local
NEXT_PUBLIC_ONTTLE_PROJECT_ID=YOUR_PROJECT_ID
NEXT_PUBLIC_ONTTLE_PUBLISHABLE_KEY=pk-proj_YOUR_PUBLISHABLE_KEY

# No NEXT_PUBLIC_: readable only in server code
ONTTLE_SECRET_KEY=sk-proj_YOUR_SECRET_KEY
One rule: only pk-proj_ keys go after NEXT_PUBLIC_.
In the browser: read a list with the publishable key
When the client needs to load data that's safe to be public, like a list of posts, use the publishable key.
// app/posts/post-list.tsx
'use client'

import { useEffect, useState } from 'react'

const PROJECT_ID = process.env.NEXT_PUBLIC_ONTTLE_PROJECT_ID
const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_ONTTLE_PUBLISHABLE_KEY

type Post = { id: string; title: string } // match your table schema

export function PostList() {
const [posts, setPosts] = useState<Post[]>([])

useEffect(() => {
fetch(
`https://api.onttle.com/v1/${PROJECT_ID}/tables/posts/contents?page=1&size=20`,
{ headers: { Authorization: `Bearer ${PUBLISHABLE_KEY}` } },
)
.then((res) => res.json())
.then((data) => setPosts(data.list))
}, [])

return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
The response comes back as list plus pagination (page, size, total) (Pagination docs). The REST API only returns published content, so drafts won't leak.

Add your domain to the key's allowed hosts when you create it, and only requests from that domain are accepted. Still, a publishable key ships in your bundle as a public value. Treat allowed hosts as a way to stop other sites from reusing your key, and only expose data through it that you're fine making public.

If the list is rendered in a server component, you don't need the browser to call the API at all. Fetching on the server with next: { revalidate: 60 } keeps the key out of the bundle and gives you caching. The publishable key is for when the browser really has to make the call.
On the server: upload with the secret key
Uploading a cover image is a write, so it needs the secret key. The browser sends the file to your own Route Handler, which forwards it to Onttle with the secret key attached.
// app/api/upload/route.ts
export async function POST(request: Request) {
// 1. Check auth first (without this, anyone can upload with your key)
// const session = await auth(); if (!session) return new Response(null, { status: 401 })

const incoming = await request.formData()
const file = incoming.get('file')
if (!(file instanceof File)) {
return Response.json({ message: 'The file field is required.' }, { status: 400 })
}

const formData = new FormData()
formData.append('file', file)
formData.append('isOcr', 'false')

const res = await fetch(
`https://api.onttle.com/v1/${process.env.NEXT_PUBLIC_ONTTLE_PROJECT_ID}/files`,
{
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ONTTLE_SECRET_KEY}` },
body: formData,
},
)

if (!res.ok) {
return Response.json({ message: `Upload failed: ${res.status}` }, { status: res.status })
}

const data = await res.json()
return Response.json({ id: data.id, url: data.presigned_url })
}
Put the returned id (a UUID) into the content's image field (for example thumbnail) to attach it to a post (File upload docs). [확인 필요: presigned_url의 만료 여부]
The most important line is comment 1. An upload route anyone can call is almost the same as publishing your secret key.
401 and 403 mean different things
Once your keys are split, these are the two errors you'll see most (Error codes docs).
401 Unauthorized: the key itself is the problem. The Authorization header is missing, the key was sent without the Bearer prefix, or the key is wrong or deleted.

403 Forbidden: the key is valid but lacks permission. You called a write, upload, chat or embedding endpoint with a publishable key, called from a host that isn't allowed, or tried to reach another project's data.

Calling upload with a publishable key returns this:
{
"type": "forbidden",
"title": "Forbidden",
"message": "This endpoint requires a secret API key (sk-proj_…). Publishable keys (pk-proj_…) are read-only."
}
Wrapping up
In short: only pk-proj_ after NEXT_PUBLIC_, writes and uploads and AI features go through sk-proj_ on the server, and server routes get auth first.
How do you split the API keys your frontend uses? In the next post, we'll wrap ask/chat and embedding search, both of which need the secret key, in a single server route to build a docs search box.
Create a project on Onttle and issue your two keys.