> ## 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.

# Personalization

> Learn how to personalize experiences for anonymous visitors

## 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

<Note>
  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.
</Note>

## Prerequisites

Before you begin, make sure you have the following:

* A copy of the [starter repository](https://github.com/fingerprintjs/use-case-tutorials) (clone with Git or download as a ZIP)
* [Node.js](https://nodejs.org/) (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](https://dashboard.fingerprint.com/signup) for a free Fingerprint trial, or log in if you already have an account.
2. After signing in, go to the [**API keys**](https://dashboard.fingerprint.com/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](https://github.com/fingerprintjs/use-case-tutorials) and open it in your editor:

```bash Terminal theme={"theme":"github-dark-dimmed"}
git clone https://github.com/fingerprintjs/use-case-tutorials.git
cd use-case-tutorials/personalization
```

2. This tutorial will be using the `personalization` folder. The project is organized as follows:

<Tree>
  <Tree.Folder name="public" defaultOpen>
    <Tree.File name="index.html - Vacation rental browsing page" />

    <Tree.File name="index.js - Frontend logic for rental listings and filters" />
  </Tree.Folder>

  <Tree.Folder name="server" defaultOpen>
    <Tree.File name="server.js - Serves static files and rental listing endpoint" />

    <Tree.File name="db.js - SQLite database connection" />

    <Tree.File name="rentals.js - Rental listing and personalization logic" />

    <Tree.Folder name="data" defaultOpen>
      <Tree.File name="rentals.json - Mock rental listing data with coordinates" />
    </Tree.Folder>
  </Tree.Folder>

  <Tree.File name=".env.example - Example environment variables" />
</Tree>

3. Install dependencies:

```bash Terminal theme={"theme":"github-dark-dimmed"}
npm install
```

4. Copy or rename `.env.example` to `.env`, then add your Fingerprint API keys:

```bash Terminal theme={"theme":"github-dark-dimmed"}
FP_PUBLIC_API_KEY=PUBLIC_API_KEY
FP_SECRET_API_KEY=SECRET_API_KEY
```

5. Start the server:

```bash Terminal theme={"theme":"github-dark-dimmed"}
npm run dev
```

6. Visit [http://localhost:3000](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](/reference/server-api-get-event) to securely retrieve the full identification details, including geolocation data.

1. At the top of `public/index.js`, load the JavaScript agent:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
const fpPromise = import(`https://fpjscdn.net/v4/${window.FP_PUBLIC_API_KEY}`).then((Fingerprint) =>
  Fingerprint.start({ region: 'us' }),
);
```

2. Make sure to change `region` to match your workspace region (e.g., `eu` for Europe, `ap` for Asia, `us` for Global (default)).

3. 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:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
let visitorId = null;
```

4. 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:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
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.

<Note>
  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.
</Note>

For lower latency in production, use [Sealed Client Results](/docs/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](/reference/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:

```javascript server/server.js theme={"theme":"github-dark-dimmed"}
app.post('/api/rentals', async (req, reply) => {
  const { eventId } = req.body;
  const result = await getRentals(eventId);
  return reply.send(result);
});
```

2. 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`:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
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,
});
```

3. Make sure to change `region` to match your workspace region (e.g., `EU` for Europe, `AP` for Asia, `Global` for Global (default)).

4. 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:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
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](https://demo.fingerprint.com/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](/docs/protecting-from-client-side-tampering).

## 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:

```text SQLite database table theme={"theme":"github-dark-dimmed"}
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:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
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());
}
```

2. Update `getRentals` to return saved filters alongside the listings:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
export async function getRentals(eventId) {
  // ...existing code...

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

  return { success: true, rentals, hotRentals, savedFilters };
}
```

3. Add a `saveFilters` export to handle filter updates from the frontend:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
export function saveFilters(visitorId, filters) {
  if (!visitorId || !filters) {
    return { success: false, error: 'Request failed.' };
  }
  saveFiltersFor(visitorId, filters);
  return { success: true };
}
```

4. Add the corresponding route to `server/server.js`:

```javascript server/server.js theme={"theme":"github-dark-dimmed"}
app.post('/api/preferences', (req, reply) => {
  const { visitorId, filters } = req.body;
  return reply.send(saveFilters(visitorId, filters));
});
```

5. 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:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
let saveTimer = null;
```

Then add the functions:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
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,
      },
    }),
  });
}
```

6. In `init()`, call `applyFilters` if saved filters are returned:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
async function init() {
  // ...existing code...

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

  render();
}
```

7. Call `debouncedSave()` after `render()` in the filter pill click handler and the price slider handler:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
// 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`:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
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 };
}
```

2. 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:

```javascript server/rentals.js theme={"theme":"github-dark-dimmed"}
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 };
}
```

3. Add the `/api/viewed` route to `server/server.js`:

```javascript server/server.js theme={"theme":"github-dark-dimmed"}
app.post('/api/viewed', (req, reply) => {
  const { visitorId, rentalId } = req.body || {};
  return reply.send(addRecentlyViewed(visitorId, rentalId));
});
```

4. In `public/index.js`, add a `saveViewed` function and call it from `openModal`:

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
async function saveViewed(rentalId) {
  await fetch('/api/viewed', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ visitorId, rentalId }),
  });
}
```

```javascript public/index.js theme={"theme":"github-dark-dimmed"}
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.

<Info>
  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.
</Info>

## 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](http://localhost:3000):

```bash Terminal theme={"theme":"github-dark-dimmed"}
npm run dev
```

2. Notice that the listings are already sorted with cities closest to your location at the top with no interaction required.
3. Apply some filters and click a few rental cards to view their details.
4. 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](/docs/smart-signals-reference), 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:

* [Node SDK Reference](https://github.com/fingerprintjs/node-sdk)
* [Vue frontend quickstart](/docs/vue-quickstart)
* [React frontend quickstart](/docs/react-quickstart)
* [API reference for the Events endpoint](/reference/server-api-get-event)
* [Low-latency identification with Sealed Client Results](/docs/sealed-client-results)
