Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.fingerprint.com/llms.txt

Use this file to discover all available pages before exploring further.

Overview

This tutorial walks through implementing Fingerprint to personalize the experience for anonymous visitors, without requiring them to create an account or log in. You’ll begin with a starter app that includes a mock vacation rental site with a list of properties and basic filtering. From there, you’ll add the JavaScript agent to identify each visitor and use server-side logic with Fingerprint data to show rentals closest to the visitor’s location on their first visit, and restore their viewing history and saved filters when they return. By the end, you’ll have a sample app that delivers a tailored experience to every visitor from the moment they land, and remembers their preferences across sessions. This tutorial uses just plain JavaScript and a Node server with SQLite on the backend. For language- or framework-specific setups, see the quickstarts.
Estimated time: < 15 minutes
Using Fingerprint for personalization may require visitor consent depending on your local regulations, similar to the requirements around cookies. The incognito mode behavior demonstrated in this tutorial is intended to make it easy to simulate a returning visitor during development. We do not recommend using Fingerprint to persist personalization specifically in incognito mode in production because it conflicts with the users’ expectations and may lead to a bad experience.

Prerequisites

Before you begin, make sure you have the following:
  • A copy of the starter repository (clone with Git or download as a ZIP)
  • Node.js (v20 or later) and npm installed
  • Your favorite code editor
  • Basic knowledge of JavaScript

1. Create a Fingerprint account and get your API keys

  1. Sign up for a free Fingerprint trial, or log in if you already have an account.
  2. After signing in, go to the API keys page in the dashboard.
  3. Save your public API key, which you’ll use to initialize the JavaScript agent.
  4. Create and securely store a secret API key for your server. Never expose it on the client side. You’ll use this key on the backend to retrieve full visitor information through the Fingerprint Server API.

2. Set up your project

  1. Clone or download the starter repository and open it in your editor:
Terminal
git clone https://github.com/fingerprintjs/use-case-tutorials.git
cd use-case-tutorials/personalization
  1. This tutorial will be using the personalization folder. The project is organized as follows:
public
index.html - Vacation rental browsing page
index.js - Frontend logic for rental listings and filters
server
server.js - Serves static files and rental listing endpoint
db.js - SQLite database connection
rentals.js - Rental listing and personalization logic
data
rentals.json - Mock rental listing data with coordinates
.env.example - Example environment variables
  1. Install dependencies:
Terminal
npm install
  1. Copy or rename .env.example to .env, then add your Fingerprint API keys:
Terminal
FP_PUBLIC_API_KEY=PUBLIC_API_KEY
FP_SECRET_API_KEY=SECRET_API_KEY
  1. Start the server:
Terminal
npm run dev
  1. Visit http://localhost:3000 to view the mock vacation rental site from the starter app. You’ll see a “Hot Rentals” section showing three randomly selected properties, followed by the full listing grid with filters for property type, guests, bedrooms, amenities, and price. Click any card to open a detail view with the full description and amenity list. Try adjusting the filters, then open the page in a new tab and you’ll see that your selections are gone. By default, the hot rentals are random and the app has no memory of previous visits.

3. Add Fingerprint to the frontend

In this step, you’ll load the JavaScript agent when the page loads and trigger identification when fetching rental listings. The JavaScript agent returns both a visitor_id and an event_id. You’ll send the event_id to your server along with the request for rentals. The server will then call the Fingerprint Events API to securely retrieve the full identification details, including geolocation data.
  1. At the top of public/index.js, load the JavaScript agent:
public/index.js
const fpPromise = import(`https://fpjscdn.net/v4/${window.FP_PUBLIC_API_KEY}`).then((Fingerprint) =>
  Fingerprint.start({ region: 'us' }),
);
  1. Make sure to change region to match your workspace region (e.g., eu for Europe, ap for Asia, us for Global (default)).
  2. In public/index.js, the page already calls init() on startup to fetch and render listings. Add visitorId to the state variables at the top of the file so it can be reused for saving preferences later:
public/index.js
let visitorId = null;
  1. Inside init(), request visitor identification from Fingerprint using the get() method. Store the visitor ID for later use and include the event ID when fetching rentals so the server can find rentals closest to the visitor’s location:
public/index.js
async function init() {
  const fp = await fpPromise;
  const result = await fp.get();
  visitorId = result.visitor_id;

  try {
    const response = await fetch("/api/rentals", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ eventId: result.event_id }),
    });

    const data = await response.json();

    // ...
  }
}
The get() method sends signals collected from the browser to Fingerprint servers, where they are analyzed to identify the visitor. The returned event_id acts as a reference to this specific identification event, which your server can later use to fetch the full visitor details, including geolocation data.
This personalization use case demo is using the visitor_id from fp.get() directly. For fraud or security-sensitive use cases, never trust the visitor_id from the client, always retrieve it server-side using getEvent() so it can’t be tampered with.
For lower latency in production, use Sealed Client Results to return full identification details as an encrypted payload from the get() method.

4. Sort rentals by proximity

Next, pass the eventId through to your rental listing logic, initialize the Fingerprint Node Server SDK, and fetch the full visitor identification event. You’ll use the visitor’s geolocation data to sort listings so that before they’ve interacted with anything, the listings are already arranged to be nearest to them.
  1. In the backend, the server/server.js file defines the API routes for the app. Update the /api/rentals route to accept a POST request, extract eventId from the request body, and pass it into the getRentals function:
server/server.js
app.post('/api/rentals', async (req, reply) => {
  const { eventId } = req.body;
  const result = await getRentals(eventId);
  return reply.send(result);
});
  1. The server/rentals.js file contains the logic for fetching and personalizing rental listings. Start by importing and initializing the Fingerprint Node Server SDK there, and load your environment variables with dotenv:
server/rentals.js
import fs from 'fs';
import { db } from './db.js';
import { config } from 'dotenv';
import { FingerprintServerApiClient, Region } from '@fingerprint/node-sdk';

config();

const fpServerApiClient = new FingerprintServerApiClient({
  apiKey: process.env.FP_SECRET_API_KEY,
  region: Region.Global,
});
  1. Make sure to change region to match your workspace region (e.g., EU for Europe, AP for Asia, Global for Global (default)).
  2. Update the getRentals function to call getEvent(), extract the visitor’s coordinates, and sort the listings by distance. Every rental in rentals.json includes a lat and lng for its city and you can sort by squared Euclidean distance:
server/rentals.js
export async function getRentals(eventId) {
  const event = await fpServerApiClient.getEvent(eventId);
  const { latitude, longitude } = event.ip_info.v4.geolocation || {};

  const rentals = JSON.parse(fs.readFileSync('./server/data/rentals.json', 'utf-8'));

  const hotRentals = rentals.slice(0, 3);
  if (latitude && longitude) {
    rentals.sort((a, b) => {
      const da = (a.lat - latitude) ** 2 + (a.lng - longitude) ** 2;
      const db = (b.lat - latitude) ** 2 + (b.lng - longitude) ** 2;
      return da - db;
    });
  }

  return { success: true, rentals, hotRentals };
}
Using the eventId, the getEvent method retrieves the full data for the visitor identification event, including geolocation, device and browser details, and Smart Signals like bot detection, VPN detection, and more. You can see a full example of the event structure in the demo playground. Now every visitor sees listings sorted with the closest cities at the top. Visitors in London will see London and European properties first; visitors in San Francisco will see West Coast rentals at the top. For checks to ensure the validity of the data coming from your frontend, view how to protect from client-side tampering and replay attacks.

5. Persist visitor filters across sessions

Next, use the visitorId from fp.get() to remember each visitor’s filter choices. When they return their filters will be restored automatically. Unlike fraud prevention use cases where every request should be verified server-side via getEvent(), personalization carries much lower risk. Since you already receive the visitor ID in the frontend, you can send it directly with preference saves rather than making a new getEvent() call each time. This keeps things simple and avoids unnecessary API calls. The starter app includes a SQLite database with the following table already created for you:
SQLite database table
visitor_preferences - Stores preferences for each visitor
  visitorId       TEXT PRIMARY KEY
  filters         TEXT NOT NULL DEFAULT '{}' (JSON string)
  recentlyViewed  TEXT NOT NULL DEFAULT '[]' (JSON array of rental IDs)
  updatedAt       INTEGER NOT NULL DEFAULT 0
  1. Add helper functions to server/rentals.js to read and write filter preferences:
server/rentals.js
function getFiltersFor(visitorId) {
  const row = db
    .prepare(`SELECT filters FROM visitor_preferences WHERE visitorId = ?`)
    .get(visitorId);
  return row ? JSON.parse(row.filters) : null;
}

function saveFiltersFor(visitorId, filters) {
  db.prepare(
    `
    INSERT INTO visitor_preferences (visitorId, filters, updatedAt)
    VALUES (?, ?, ?)
    ON CONFLICT(visitorId) DO UPDATE SET filters = excluded.filters, updatedAt = excluded.updatedAt
  `,
  ).run(visitorId, JSON.stringify(filters), Date.now());
}
  1. Update getRentals to return saved filters alongside the listings:
server/rentals.js
export async function getRentals(eventId) {
  // ...existing code...

  const visitorId = event.identification.visitor_id;
  const savedFilters = getFiltersFor(visitorId);

  return { success: true, rentals, hotRentals, savedFilters };
}
  1. Add a saveFilters export to handle filter updates from the frontend:
server/rentals.js
export function saveFilters(visitorId, filters) {
  if (!visitorId || !filters) {
    return { success: false, error: 'Request failed.' };
  }
  saveFiltersFor(visitorId, filters);
  return { success: true };
}
  1. Add the corresponding route to server/server.js:
server/server.js
app.post('/api/preferences', (req, reply) => {
  const { visitorId, filters } = req.body;
  return reply.send(saveFilters(visitorId, filters));
});
  1. Back in public/index.js, add an applyFilters function that restores saved filter state and syncs the UI, a saveFilters function that posts the current state, and a debouncedSave wrapper so rapid filter changes are batched into a single request. Add saveTimer to the state variables at the top of the file:
public/index.js
let saveTimer = null;
Then add the functions:
public/index.js
function applyFilters(saved) {
  saved.activeTypes?.forEach((t) => activeTypes.add(t));
  saved.activeAmenities?.forEach((a) => activeAmenities.add(a));
  minGuests = saved.minGuests ?? null;
  minBedrooms = saved.minBedrooms ?? null;
  maxPrice = saved.maxPrice ?? MAX_PRICE_CEILING;

  document.querySelectorAll('.filter-pill').forEach((btn) => {
    const { filter, value } = btn.dataset;
    let isActive = false;
    switch (filter) {
      case 'type':
        isActive = activeTypes.has(value);
        break;
      case 'amenity':
        isActive = activeAmenities.has(value);
        break;
      case 'guests':
        isActive = Number(value) === minGuests;
        break;
      case 'bedrooms':
        isActive = Number(value) === minBedrooms;
        break;
    }
    btn.toggleAttribute('data-active', isActive);
  });

  document.getElementById('priceSlider').value = maxPrice;
  document.getElementById('priceLabel').textContent =
    maxPrice >= MAX_PRICE_CEILING ? 'Any price' : `$${maxPrice}/night`;
}

function debouncedSave() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(saveFilters, 1000);
}

async function saveFilters() {
  if (!visitorId) return;

  await fetch('/api/preferences', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      visitorId,
      filters: {
        activeTypes: [...activeTypes],
        activeAmenities: [...activeAmenities],
        minGuests,
        minBedrooms,
        maxPrice,
      },
    }),
  });
}
  1. In init(), call applyFilters if saved filters are returned:
public/index.js
async function init() {
  // ...existing code...

  if (data.savedFilters) applyFilters(data.savedFilters);

  render();
}
  1. Call debouncedSave() after render() in the filter pill click handler and the price slider handler:
public/index.js
// In the filter pill click handler:
render();
debouncedSave();

// In the price slider handler:
render();
debouncedSave();

6. Show recently viewed rentals

When a visitor clicks to view a rental’s details, save the rental ID. On their next visit, the “Hot Rentals” section will show those properties instead of a random selection.
  1. Add a getRecentlyViewedFor helper and an addRecentlyViewed export to server/rentals.js:
server/rentals.js
function getRecentlyViewedFor(visitorId) {
  const row = db
    .prepare(`SELECT recentlyViewed FROM visitor_preferences WHERE visitorId = ?`)
    .get(visitorId);
  return row ? JSON.parse(row.recentlyViewed) : null;
}

export function addRecentlyViewed(visitorId, rentalId) {
  if (!visitorId || !rentalId) {
    return { success: false, error: 'Request failed.' };
  }
  const current = getRecentlyViewedFor(visitorId) ?? [];
  const updated = [rentalId, ...current.filter((id) => id !== rentalId)].slice(0, 10);
  db.prepare(
    `
    INSERT INTO visitor_preferences (visitorId, recentlyViewed, updatedAt)
    VALUES (?, ?, ?)
    ON CONFLICT(visitorId) DO UPDATE SET recentlyViewed = excluded.recentlyViewed, updatedAt = excluded.updatedAt
  `,
  ).run(visitorId, JSON.stringify(updated), Date.now());
  return { success: true };
}
  1. Update getRentals to use recently viewed IDs for the hotRentals response, falling back to the 3 random rentals if the visitor hasn’t viewed anything yet:
server/rentals.js
export async function getRentals(eventId) {
  // ...existing code...

  const viewedIds = getRecentlyViewedFor(visitorId) ?? [];
  const hotRentals =
    viewedIds.length > 0
      ? viewedIds.map((id) => rentals.find((r) => r.id === id)).filter(Boolean)
      : rentals.slice(0, 3);

  return { success: true, rentals, hotRentals, savedFilters };
}
  1. Add the /api/viewed route to server/server.js:
server/server.js
app.post('/api/viewed', (req, reply) => {
  const { visitorId, rentalId } = req.body || {};
  return reply.send(addRecentlyViewed(visitorId, rentalId));
});
  1. In public/index.js, add a saveViewed function and call it from openModal:
public/index.js
async function saveViewed(rentalId) {
  await fetch('/api/viewed', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ visitorId, rentalId }),
  });
}
public/index.js
function openModal(rental) {
  // ...existing code...

  document.getElementById('rentalModal').classList.remove('hidden');
  saveViewed(rental.id);
}
On return visits, the Hot Rentals section will now show the properties the visitor previously clicked on instead of a random selection.
This is a minimal example to show how to implement Fingerprint. In a real application, make sure to implement proper security practices, error checking, and data handling that align with your production standards and local regulations.

7. Test your implementation

Now that everything is wired up, you can test the full personalized experience.
  1. Start your server if it isn’t already running and open http://localhost:3000:
Terminal
npm run dev
  1. Notice that the listings are already sorted with cities closest to your location at the top with no interaction required.
  2. Apply some filters and click a few rental cards to view their details.
  3. Open the page in a private or incognito browser window. Your filters will be restored, the listings will again be sorted by proximity, and the Hot Rentals section will show the properties you just viewed.

Next steps

You now have a working vacation rental site that sorts listings by proximity, remembers visitor filters, and surfaces recently viewed properties, all without requiring a login. From here, you can expand the logic with more Smart Signals, add additional preference types, or use the visitor ID to power other personalization features like personalized recommendations. To dive deeper, explore the other use case tutorials for more step-by-step examples. Check out these related resources: