Skip to content

Plain HTML

This guide walks through adding Latch to a plain HTML site using only fetch(). No SDK, no build framework, no dependencies beyond a dev server.

  • Generates and persists an anonymous visitor ID in localStorage
  • Calls the Latch REST API to check whether the current reader can view the page
  • Shows a paywall card when access is denied, with a meter countdown for metered rules
  • Sends pageview and article-view events to the Latch event endpoint

The full working demo is in apps/plain-html-demo/ in the Latch repository.

Before starting, you need:

  • A running Latch instance (API and Dashboard)
  • A product with at least one paid price
  • A metered paywall rule matching your premium URLs (for example, URL contains /article/)
  • A publishable key from Settings > API Keys
  • CORS_ORIGIN on the API configured to include this demo’s origin (http://localhost:5174)

See the Quickstart guide for step-by-step setup of all of the above.

site/
index.html
src/
config.js
main.js
styles.css

Create a minimal index.html with an app container and a module script:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Latch Plain HTML Demo</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

The type="module" attribute lets you use import statements in main.js. The #app div is where the application renders content and paywall UI.

Create src/config.js with three values:

export const LATCH_API_URL = 'http://localhost:4000';
export const LATCH_PUBLISHABLE_KEY = 'pk_your_publishable_key';
export const SUBSCRIBE_URL = '/subscribe';
ValueDescription
LATCH_API_URLThe base URL of your Latch API instance
LATCH_PUBLISHABLE_KEYYour publishable key from Settings > API Keys in the dashboard. Always starts with pk_.
SUBSCRIBE_URLWhere the subscribe button links to. Point this at your own checkout page.

In src/main.js, start by generating a stable anonymous ID for the current browser:

import { LATCH_API_URL, LATCH_PUBLISHABLE_KEY, SUBSCRIBE_URL } from './config.js';
const STORAGE_KEY = 'latch_anon_id';
function getAnonymousId() {
let id = localStorage.getItem(STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, id);
}
return id;
}

This ID persists across sessions and is sent with both access checks and events. Latch uses it to count metered views per visitor, so a reader who has used 2 of 3 free views will see the correct remaining count on their next visit.

Add the checkAccess() function that calls the Latch REST API:

async function checkAccess(url) {
const params = new URLSearchParams({
url,
anonymousId: getAnonymousId(),
});
try {
const res = await fetch(`${LATCH_API_URL}/api/v1/access/check?${params}`, {
headers: { 'X-API-Key': LATCH_PUBLISHABLE_KEY },
});
if (!res.ok) return { granted: true, reason: 'error_fallback' };
return res.json();
} catch {
return { granted: true, reason: 'error_fallback' };
}
}

Key details:

  • Endpoint: GET /api/v1/access/check with the publishable key in the X-API-Key header.
  • Query parameters: url is the canonical URL of the content being checked. anonymousId is the visitor’s persistent ID from Step 3.
  • Fail-open pattern: if the API is unreachable or returns an error, the function returns { granted: true, reason: 'error_fallback' }. Readers are never blocked by a network issue.
  • Identified users: if you have a logged-in user ID from your own auth system, add userId as an additional query parameter so Latch can check subscription entitlements.

Add the trackEvent() function to send engagement data to Latch:

async function trackEvent(eventType, properties = {}) {
try {
await fetch(`${LATCH_API_URL}/api/v1/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': LATCH_PUBLISHABLE_KEY,
},
body: JSON.stringify({
events: [{ eventType, properties, anonymousId: getAnonymousId() }],
}),
});
} catch {
// Ignore tracking errors — never block the reader
}
}
  • Endpoint: POST /api/v1/events with the publishable key.
  • The events array can batch multiple events in a single request.
  • Common event types: pageview, article_view. You can add any custom properties you need.
  • Errors are silently ignored so tracking never interferes with the reading experience.

After checking access, render the page based on the result:

const articleUrl = `${window.location.origin}/article/${slug}`;
const result = await checkAccess(articleUrl);
if (result.granted) {
// Show the full article
renderFullArticle();
// Show meter info if available
if (result.meterRemaining !== undefined) {
showMeterBanner(result.meterRemaining);
}
} else {
// Show a preview and paywall card
renderPreview();
showPaywall(result.paywallRule?.action?.message);
}
// Track engagement regardless of access outcome
await trackEvent('pageview', { path: window.location.pathname });
await trackEvent('article_view', { slug, granted: result.granted });

The access result drives three states:

ConditionWhat to show
granted: true, no meterRemainingFull article, no banner
granted: true with meterRemainingFull article with a meter countdown (e.g. “2 free premium articles remaining”)
granted: falseArticle preview with a paywall card

When access is denied, result.paywallRule.action.message contains the paywall copy you configured in the dashboard. Use it as the paywall heading. Link the subscribe button to your own checkout page — keep checkout server-side where you can use the secret key.

The demo uses Vite as a simple dev server. No build step is required.

If you are working from your own project, start the dev server with:

Terminal window
npx vite --port 5174

If you are running the demo from the Latch repository:

Terminal window
pnpm --filter @latch/plain-html-demo dev

Then:

  1. Open http://localhost:5174
  2. Visit a premium article — it should load with a meter banner showing remaining free views
  3. Visit premium articles until you exceed the meter limit (e.g. 3 views for a limit of 3)
  4. On the next premium visit, access should be denied and the paywall card should appear
  • Use only the publishable key (pk_) in browser code. Keep checkout and subscription management on your server where the secret key (sk_) is safe.
  • If you have a logged-in user ID from your own auth system, pass it as userId in the access check query parameters so Latch can match subscriptions to the reader.
  • Soft paywall rules return granted: true with a paywallRule attached — inspect the result if you want to show a promotional prompt without blocking access.
  • If browser requests fail immediately, check that CORS_ORIGIN on the Latch API includes your site’s origin.
  • This example sends events immediately for clarity. The JavaScript SDK provides the same functionality with automatic batching, built-in paywall UI, and customer auth if you want a higher-level integration.