How to add per-user cloud storage to a Clerk app
Clerk signs people in. It doesn’t give you somewhere to keep each person’s settings, progress or drafts. Here are the ways to add that, what each one costs you, and which to pick.
Written by jstash, which makes one of the options compared here. Updated 4 October 2026.
Summary
- A few small values the person may change themselves: Clerk’s own
unsafeMetadata. Nothing else to set up, but 8 KB of metadata in total, and the person can edit it. - Data you need to trust, query or share: a database. Supabase accepts Clerk sign-ins and protects rows with policies you write. Convex and your own backend check the user in code you write.
- Each person’s own JSON, saved from the browser, with no server or rules: jstash Apps checks the Clerk session token itself. Free covers 1 app and 100 users.
- Firebase: Clerk’s Firebase integration is deprecated and can’t be turned on for new apps.
Option 1: Clerk user metadata
Every Clerk user has three metadata fields:
publicMetadataReadRead and writeprivateMetadataNo accessRead and writeunsafeMetadataRead and writeRead and writeOnly unsafeMetadata can be written from the browser, so it’s the one a frontend-only app can use:
// Merged into the signed-in user's unsafeMetadata
await Clerk.user.updateMetadata({ unsafeMetadata: { theme: 'dark' } });
// Later, on any device they sign in on
const theme = Clerk.user.unsafeMetadata.theme;
- Size: all of a user’s metadata together is limited to 8 KB. If you also copy it into the session token, Clerk recommends keeping that under 1.2 KB, because the token lives in a cookie. For more than that, Clerk suggests your own database (Clerk docs).
- Trust: it’s called unsafe because “malicious users could potentially tamper with these values”. Fine for a theme or an onboarding flag. Not for a plan, credits, or anything else people shouldn’t set themselves.
- Shape: one object per user. Good for a handful of preferences; awkward for lists that grow, like notes or saved items.
If a few preferences are all you need, stop here. It’s the simplest option, with nothing new to sign up for.
Option 2: Supabase, with Clerk as the login
Supabase can accept Clerk session tokens as a third-party login. You turn on the Supabase integration in Clerk, add Clerk as a provider in Supabase, then write row level security (RLS) policies that compare each row’s owner with the Clerk user ID in the token. From Clerk’s guide:
create table tasks( id serial primary key, name text not null, user_id text not null default auth.jwt()->>'sub' ); alter table "tasks" enable row level security; create policy "User can view their own tasks" on "public"."tasks" for select to authenticated using ( ((select auth.jwt()->>'sub') = (user_id)::text) ); -- plus a policy for insert, and for update and delete if people may do those
- Good: a real Postgres database, with queries, shared data and live updates. The free plan includes 50,000 monthly active users and a 500 MB database.
- Cost: a table, policies and a client library to set up, and the policies are yours to get right. In Supabase’s words, “a table in an exposed schema without RLS is readable and writable by any role with a grant on it”. Free projects pause after a week without enough database activity.
Option 3: Convex, or your own backend
Convex is a hosted backend with a realtime database that accepts Clerk sign-ins. Instead of rules, you write functions, and each one that touches private data checks who’s calling with ctx.auth.getUserIdentity() (Convex docs).
Your own backend works the same way. An API route or serverless function verifies the Clerk session token, with authenticateRequest() or against your instance’s public keys, checks its azp claim is one of your sites (Clerk docs), then reads and writes your database by user ID.
- Good: any logic you like, and data you can query, share, validate and export.
- Cost: server code to write and maintain, and a check in every function or route that touches someone’s data.
What about Firebase?
Firestore’s security rules see users through Firebase Authentication. Clerk’s Firebase integration, which bridged the two, is deprecated: “For new applications, you cannot enable the integration.” With Clerk as your login, pick one of the other options.
Option 4: jstash Apps
jstash Apps, which we make, keeps private JSON documents for each signed-in person and checks the Clerk session token itself. There’s no server, no table, no policy and no jstash key in your page.
Set it up
- Sign in to the jstash dashboard with GitHub and open Apps → New app.
- Choose Clerk and paste your Frontend API URL, from the Domains page in Clerk, or your publishable key.
- Add every website address where people sign in, like
https://my-app.netlify.app(up to 5). Turn on Allow localhost while you develop. - Copy the starter code from the app’s page. It has your app’s address filled in. There’s also a ready-made prompt for your coding agent.
The code
const JSTASH = 'https://api.jstash.app/v1/apps/app_…/me';
// In React, use getToken from useAuth() instead
function token() {
if (!Clerk.session) throw new Error('Not signed in');
return Clerk.session.getToken(); // Clerk refreshes it for you
}
export async function save(name, value) {
const res = await fetch(`${JSTASH}/${name}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${await token()}` },
body: JSON.stringify(value),
});
if (!res.ok) throw await failure(res);
}
export async function load(name) {
const res = await fetch(`${JSTASH}/${name}`, {
headers: { Authorization: `Bearer ${await token()}` },
});
if (res.ok) return res.json();
const err = await failure(res);
if (err.code === 'DOC_NOT_FOUND') return null; // nothing saved yet
throw err;
}
// jstash errors are JSON: {"error": {"code", "message"}}
async function failure(res) {
const { error } = await res.json()
.catch(() => ({ error: { message: `jstash returned ${res.status}` } }));
return Object.assign(new Error(error.message), { code: error.code });
}
Call load('settings') once someone is signed in (it returns null until they’ve saved something), and save('settings', value) when it changes. Each person reaches only their own documents.
Clerk details that matter
- Get a token for each request. Clerk session tokens last 60 seconds.
getToken()uses a cache and only asks Clerk for a new one once the old one has expired, so calling it every time is cheap. Use the default session token, not a JWT template. - Development and production are separate. Each Clerk instance has its own Frontend API URL and its own users, so each needs its own jstash app. Free includes 1 app, so you can connect one instance; Pro has 3.
- List every site that signs people in. A Clerk token names the website it was made on, and jstash refuses tokens made on a website the app doesn’t list.
- Not supported yet: Clerk’s proxy and satellite domains. People with a step left to finish in Clerk, like choosing an organization, are refused until they finish it.
- Per person, not per organization. Each document belongs to one person. Data a team or organization shares needs a database.
Limits and trade-offs
- Free: 1 app and up to 100 people across your account, each with up to 200 documents of up to 100 KB, within your account’s 10 MB; 100 saves per person and 500 per app a day. Pro has 3 apps, 1,000 people and 1 MB documents, and will be US$5 a month; it’s invite-only until paid plans open. See all limits.
- Small on purpose. Clerk’s free plan covers 50,000 monthly retained users per app. If more than 1,000 people will save data in your app, jstash Apps is too small: use a database.
- What it doesn’t do: jstash gives you no way to read your users’ documents, and there are no queries, live sync or backups. Don’t store sensitive data such as health information.
- Tokens pass through jstash. Each request trusts jstash with a Clerk token that works for up to a minute. jstash uses it only to check who’s signed in, and doesn’t store or log it.
Which one to use
| Option | Room per person | Saved from the browser | Server code | Access rules |
|---|---|---|---|---|
Clerk unsafeMetadata | 8 KB, all metadata | Yes, and the person can edit it | No | None |
| Supabase with Clerk | Rows in Postgres | Yes | No | RLS policies you write |
| Convex or your own backend | Your database | Through your functions | Yes | A check in each function |
| jstash Apps | 200 documents of 100 KB (Free) | Yes | No | Built in, one rule |
- A few preferences: Clerk metadata.
- Shared data, queries, an admin view, or more than 1,000 people saving: Supabase, Convex or your own backend.
- Each person’s own settings, progress or drafts, without running a server or writing policies: jstash Apps.
More guides
- How to save user data in a static website
- localStorage vs cloud storage for small web apps
- How to save JSON from a frontend without exposing an API key
- How to add a database to a static website — and when you don’t need one