Guides

Sync a plain HTML app across devices with Sign In With Google

jstash Apps stores private JSON documents for each signed-in user of your web app, so they can load their data on another device. Sign In With Google identifies the person; jstash checks their Google ID token on each request. Your app needs no server, SDK, jstash API key in the page or security rules to write.

What you need

You need a Google Cloud OAuth client ID of type Web application, a jstash account and a static file server. The working example uses plain HTML and JavaScript, with no build step.

The browser sends the signed-in person’s Google ID token to jstash. jstash checks its signature against Google’s published keys, its issuer, its audience against your client ID and its expiry. Each person can only read and write their own documents.

Set up Google, then jstash

In the Google Cloud console, go to APIs & Services → Credentials → Create credentials → OAuth client ID → Web application.

If Google asks, set up the consent screen first. An app name and your email are enough for testing.

Under Authorized JavaScript origins, add both local testing addresses:

  • http://localhost
  • http://localhost:5173

Copy the client ID.

Sign in at app.jstash.app with GitHub or Google. Open Apps → New app, give it a name, pick Google and paste the same client ID. On the app’s page, turn on Allow localhost while developing.

Copy the app ID shown under the app’s name in Apps: app_ followed by 22 characters.

Serve your folder:

python3 -m http.server 5173

Open http://localhost:5173. Google refuses file://.

Add sign-in, save and load

Include these scripts in your HTML and an element with id="sign-in" for Google’s button:

<script src="https://accounts.google.com/gsi/client" defer></script>
<script src="app.js" defer></script>

Put this in app.js, replacing the app ID and Google client ID placeholders. Neither ID is a secret: both are in the page every visitor loads. Never put a jstash API key in a web page; the per-user endpoints refuse API keys.

const JSTASH_APP_ID = 'app_…';
const GOOGLE_CLIENT_ID = '….apps.googleusercontent.com';
const JSTASH = `https://api.jstash.app/v1/apps/${JSTASH_APP_ID}/me`;

// The signed-in person's Google ID token, kept in memory only.
let idToken = null;

google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, callback: onSignIn });
google.accounts.id.renderButton(document.getElementById('sign-in'), { theme: 'outline', size: 'large' });

// Sign In With Google calls this with the ID token in response.credential.
async function onSignIn(response) {
  idToken = response.credential;
  try {
    const notes = await load('notes'); // null if nothing is saved yet
    // show the notes on the page, then allow saving
    document.getElementById('save').disabled = false;
  } catch (err) {
    console.error(err.code, err.message);
  }
}

function token() {
  if (!idToken) throw new Error('Not signed in');
  return idToken;
}

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);
}

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"}}. Branch on the code.
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 });
}

The page needs a <textarea id="text"> and a <button id="save" disabled>. The button stays disabled until load() returns, so an empty box can’t replace saved notes.

document.getElementById('save').addEventListener('click', async () => {
  try {
    await save('notes', { text: document.getElementById('text').value, saved_at: new Date().toISOString() });
  } catch (err) {
    console.error(err.code, err.message);
  }
});

The endpoint is https://api.jstash.app/v1/apps/{appId}/me/{name}. PUT creates or replaces the whole document with any JSON value. GET returns it. A 404 with code DOC_NOT_FOUND means nothing has been saved yet.

Document names must be 1–64 characters of letters, digits, -, _ and ., not starting with a dot. Use a few fixed names such as settings or notes, never user input.

For a “delete my account” flow, DELETE https://api.jstash.app/v1/apps/{appId}/me deletes everything that person saved.

Sign In With Google with jstash has been tested end to end; the live demo uses it with real accounts. The full reference covers jstash Apps.

Load the latest copy

localStorage and IndexedDB are per browser and per device; clearing site data removes them. Keep your existing copy as the fast local copy. There is no live sync: changes aren’t pushed to other devices. Load the latest copy from jstash when the page opens or after sign-in. Save when the data changes (debounce rapid edits), never on a timer.

For 2 devices saving at once, send the ETag from the last GET/PUT as If-Match on PUT; 412 ETAG_MISMATCH means it changed — load again and retry.

Plan for sign-in and limits

Google ID tokens last 1 hour and a page can’t refresh them. The token stays in memory, so people sign in again after 1 hour or a page reload. For AUTH_INVALID, sign in again. If it fails straight after signing in, the client ID in the jstash app most likely doesn’t match GOOGLE_CLIENT_ID: signing in again won’t help, and the error message names the client ID jstash expects.

The Free plan includes:

  • 1 app, up to 100 users, no card.
  • Per user: up to 200 documents, each up to 100 KB, with 100 saves a day.
  • 500 saves a day per app.
  • 1,000 reads a day per user and 5,000 per app; every read and list counts.
  • 10 MB storage per account, shared with bins.

Daily limits reset at 00:00 UTC. Pro is available to everyone.

There are no queries or search, and each document belongs to 1 person: no shared/team data. The developer can’t read users’ documents; there is no admin view or export.

jstash keeps no old versions, so a PUT or DELETE can’t be undone. Don’t store sensitive data such as health information.

Put the app live

On the app’s page, add the site’s address under Website addresses: https, exact match, no wildcards, up to 5. Turn off Allow localhost. Add the same address to the Google client’s Authorized JavaScript origins.

A 403 FORBIDDEN that names your address means it’s missing from Website addresses.

When to use something else

For data people share, search/queries across records, an admin view of users’ data or large files/images, use a database such as Firebase or Supabase, with access rules you write.

For long sessions with no re-sign-in, keep Google as a sign-in option through Clerk, Supabase Auth or Firebase Auth. They offer “Sign in with Google” and refresh tokens for you; jstash Apps works with those too.

Next step

Start with the working example on GitHub: MIT, 1 HTML file and 1 script, no build step.