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.
What this example does
Section titled “What this example does”- 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.
Prerequisites
Section titled “Prerequisites”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_ORIGINon 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.
Project structure
Section titled “Project structure”site/ index.html src/ config.js main.js styles.cssStep 1: Create the HTML page
Section titled “Step 1: Create the HTML page”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.
Step 2: Configure Latch
Section titled “Step 2: Configure Latch”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';| Value | Description |
|---|---|
LATCH_API_URL | The base URL of your Latch API instance |
LATCH_PUBLISHABLE_KEY | Your publishable key from Settings > API Keys in the dashboard. Always starts with pk_. |
SUBSCRIBE_URL | Where the subscribe button links to. Point this at your own checkout page. |
Step 3: Anonymous visitor tracking
Section titled “Step 3: Anonymous visitor tracking”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.
Step 4: Check access
Section titled “Step 4: Check access”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/checkwith the publishable key in theX-API-Keyheader. - Query parameters:
urlis the canonical URL of the content being checked.anonymousIdis 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
userIdas an additional query parameter so Latch can check subscription entitlements.
Step 5: Track events
Section titled “Step 5: Track events”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/eventswith the publishable key. - The
eventsarray 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.
Step 6: Handle the access result
Section titled “Step 6: Handle the access result”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 outcomeawait trackEvent('pageview', { path: window.location.pathname });await trackEvent('article_view', { slug, granted: result.granted });The access result drives three states:
| Condition | What to show |
|---|---|
granted: true, no meterRemaining | Full article, no banner |
granted: true with meterRemaining | Full article with a meter countdown (e.g. “2 free premium articles remaining”) |
granted: false | Article 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.
Step 7: Run it
Section titled “Step 7: Run it”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:
npx vite --port 5174If you are running the demo from the Latch repository:
pnpm --filter @latch/plain-html-demo devThen:
- Open
http://localhost:5174 - Visit a premium article — it should load with a meter banner showing remaining free views
- Visit premium articles until you exceed the meter limit (e.g. 3 views for a limit of 3)
- 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
userIdin the access check query parameters so Latch can match subscriptions to the reader. - Soft paywall rules return
granted: truewith apaywallRuleattached — inspect the result if you want to show a promotional prompt without blocking access. - If browser requests fail immediately, check that
CORS_ORIGINon 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.