Mini Apps run as sandboxed cross-origin iframes inside the Startale App. This environment imposes browser-level storage restrictions that differ from a normal browser tab. Understanding them upfront prevents silent bugs in production.
What works and what does not
| Mechanism | Works | Notes |
|---|
localStorage | Yes | Recommended. Stable across sessions from app.startale.com. |
IndexedDB | Yes | Same origin rules as localStorage. Good for structured or large data. |
sessionStorage | Yes | Not persisted across page reloads or frame re-opens. Suitable for ephemeral in-session state only. |
JavaScript cookies (document.cookie) | No | Silently dropped on Safari and iOS due to Intelligent Tracking Prevention. See below. |
Set-Cookie from your backend | No | Same cross-origin restriction: cookies set by a third-party origin are blocked in iframes. |
| Storage Access API | No | Not available. The iframe sandbox is missing allow-storage-access-by-user-activation. |
Why cookies fail silently
Mini App URLs are always cross-origin relative to app.startale.com (all Mini Apps are hosted on their own domain). The Startale App embeds them with:
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin"
The allow-same-origin flag preserves the Mini App’s own origin (so the Mini App can access its own localStorage), but the frame is still third-party to app.startale.com.
Safari / iOS, Intelligent Tracking Prevention: ITP blocks third-party cookie storage in cross-origin iframes by default. document.cookie = "..." runs without throwing an error, but the cookie is silently dropped or scoped to ephemeral storage that does not survive navigation. This is not a bug in the Startale App; it is Safari’s enforced policy.
Chrome, CHIPS (Partitioned Cookies): Third-party cookies require the Partitioned attribute. Without it, behavior is unreliable and being phased toward full removal.
The key misconception: SameSite=None; Secure controls whether a cookie is sent on cross-site requests. It does not override ITP or Partitioned Cookie policies that block the cookie from being stored in the first place.
localStorage: the recommended approach
localStorage works reliably in the sandboxed iframe because allow-same-origin preserves the Mini App’s origin. Storage is scoped to your Mini App’s origin and is stable across repeated sessions launched from app.startale.com.
// Persist state
localStorage.setItem('game:round', JSON.stringify({ round: 5, score: 1200 }))
// Restore on re-open
const saved = localStorage.getItem('game:round')
const state = saved ? JSON.parse(saved) : { round: 1, score: 0 }
Namespace your keys. Use a consistent prefix to avoid collisions between multiple Mini Apps on the same origin:
const STORAGE_PREFIX = 'your-app-name:'
const save = (key: string, value: unknown) =>
localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value))
const restore = <T>(key: string, fallback: T): T => {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${key}`)
return raw !== null ? (JSON.parse(raw) as T) : fallback
}
Saving state on close
The reliable hooks for catching a frame close or navigation are visibilitychange and pagehide. Register them on mount and write to localStorage synchronously inside the handler.
import { sdk } from '@farcaster/miniapp-sdk'
function saveState(state: GameState) {
localStorage.setItem('your-app-name:state', JSON.stringify(state))
}
// Register on mount
const handleHide = () => {
if (document.visibilityState === 'hidden') {
saveState(currentState)
}
}
const handlePageHide = () => {
saveState(currentState)
}
document.addEventListener('visibilitychange', handleHide)
window.addEventListener('pagehide', handlePageHide)
Write synchronously inside pagehide. The browser may freeze the JavaScript thread immediately after the event fires, so async operations including IndexedDB writes are not reliable at this point. Use localStorage.setItem; it is synchronous.
Restore state on mount before calling sdk.actions.ready():
useEffect(() => {
const saved = localStorage.getItem('your-app-name:state')
if (saved) {
restoreState(JSON.parse(saved))
}
sdk.actions.ready()
}, [])
Backend authentication: replace cookies with Bearer tokens
If your Mini App authenticates against a backend API, return the session token in the JSON response body and store it in localStorage. Do not rely on Set-Cookie.
// Auth flow: store token from response body, not from Set-Cookie
const { token } = await fetch('https://api.your-app.com/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
}).then((r) => r.json())
localStorage.setItem('your-app-name:auth_token', token)
// Subsequent authenticated requests
const data = await fetch('https://api.your-app.com/deposit', {
headers: {
Authorization: `Bearer ${localStorage.getItem('your-app-name:auth_token')}`,
},
}).then((r) => r.json())
Your backend must accept Authorization: Bearer <token> instead of reading from the Cookie header.
IndexedDB: structured or large data
For structured data, larger payloads, or binary assets, use IndexedDB. It follows the same origin rules as localStorage and works reliably inside the iframe.
import { openDB } from 'idb'
const db = await openDB('your-app-name', 1, {
upgrade(db) {
db.createObjectStore('state')
},
})
// Write
await db.put('state', { round: 5, score: 1200 }, 'game')
// Read
const saved = await db.get('state', 'game')
Do not write to IndexedDB inside a pagehide handler. IndexedDB operations are asynchronous and will not complete before the browser freezes the thread. Use localStorage.setItem for any state that must be saved on close.