Web Setup Guide

Last updated on Apr 04, 2026

Web setup tutorial

Turn any SPA or SSR site into a LinkMe-aware experience. The example below focuses on Next.js (App Router) because it matches our marketing site, but the same SDK works everywhere.

Before you start

  • LinkMe app with an API key (read-only scope is enough for the browser)
  • Custom domain or default li-nk.me domain for links
  • Modern build tooling (Next.js 13+, Vite, etc.)

1. Install the SDK

npm install @li-nk.me/web-sdk

Package: @li-nk.me/web-sdk

The package is zero-dependency TypeScript that works in any framework—even vanilla JS.

2. Configure environment variables

Expose your app ID (and optional read-only key) to the client:

# .env.local
NEXT_PUBLIC_LINKME_APP_ID=app_123
NEXT_PUBLIC_LINKME_APP_READ_KEY=app_live_read_...

Use public prefixes as required by your framework (e.g., VITE_ for Vite, NEXT_PUBLIC_ for Next.js).

Tip: pass debug: process.env.NODE_ENV !== 'production' to configure() while developing to see URL parsing and deferred-claim requests logged in the browser console.

3. Bootstrap in Next.js (App Router example)

'use client';

import { ReactNode, useEffect } from 'react';
import { configure, resolveFromUrl, onLink, claimDeferredIfAvailable } from '@li-nk.me/web-sdk';
import { useRouter } from 'next/navigation';

export function LinkMeProvider({ children }: { children: ReactNode }) {
  const router = useRouter();

  useEffect(() => {
    let mounted = true;
    let unsubscribe: { remove: () => void } | null = null;

    (async () => {
      await configure({
        baseUrl: 'https://links.yourco.com',
        appId: process.env.NEXT_PUBLIC_LINKME_APP_ID!,
        appKey: process.env.NEXT_PUBLIC_LINKME_APP_READ_KEY,
      });

      const initial = await resolveFromUrl();
      if (mounted && initial?.path) {
        router.replace(initial.path.startsWith('/') ? initial.path : `/${initial.path}`);
      } else {
        const deferred = await claimDeferredIfAvailable();
        if (mounted && deferred?.path) {
          router.replace(deferred.path.startsWith('/') ? deferred.path : `/${deferred.path}`);
        }
      }

      unsubscribe = onLink((payload) => {
        if (!payload?.path) return;
        router.replace(payload.path.startsWith('/') ? payload.path : `/${payload.path}`);
      });
    })();

    return () => {
      mounted = false;
      unsubscribe?.remove();
    };
  }, [router]);

  return <>{children}</>;
}

Wrap your app layout:

// app/layout.tsx
import { LinkMeProvider } from './LinkMeProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <LinkMeProvider>{children}</LinkMeProvider>
      </body>
    </html>
  );
}

4. SPA frameworks (React/Vue/Svelte)

Call the same bootstrap logic from a custom hook or effect:

import { useEffect } from 'react';
import { configure, resolveFromUrl, onLink } from '@li-nk.me/web-sdk';
import { useNavigate } from 'react-router-dom';

export function useLinkMe() {
  const navigate = useNavigate();

  useEffect(() => {
    let cleanup: (() => void) | null = null;
    (async () => {
      await configure({ appId: import.meta.env.VITE_LINKME_APP_ID });
      const initial = await resolveFromUrl();
      if (initial?.path) navigate(initial.path);
      const sub = onLink((payload) => payload?.path && navigate(payload.path));
      cleanup = () => sub.remove();
    })();
    return () => cleanup?.();
  }, [navigate]);
}

5. Test locally

  • Run npm run dev, click a LinkMe slug that points to your domain, and confirm the page swaps without a full reload.
  • Open the same slug in an incognito window to validate deferred flow (the SDK will call /api/deferred/claim).
  • Use your browser's network tab to confirm cid claims return payload JSON.

Next steps

  • Review the Web SDK reference for every export (configure, onLink, resolveFromUrl, etc.).
  • Pair this with your marketing site or portal by hosting a minimal provider component like the one above.

Svelte

import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { configure, resolveFromUrl, onLink } from '@li-nk.me/web-sdk';

onMount(() => {
  let active = true;
  (async () => {
    await configure({
      appId: import.meta.env.VITE_LINKME_APP_ID,
      appKey: import.meta.env.VITE_LINKME_APP_READ_KEY,
    });
    const initial = await resolveFromUrl();
    if (active && initial?.path) goto(initial.path, { replaceState: true });
  })();
  const { remove } = onLink((payload) => {
    if (payload?.path) goto(payload.path);
  });
  return () => {
    active = false;
    remove();
  };
});

Angular

import { Injectable, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { configure, onLink, resolveFromUrl } from '@li-nk.me/web-sdk';

@Injectable({ providedIn: 'root' })
export class LinkMeService implements OnDestroy {
  private teardown?: () => void;

  constructor(private readonly router: Router) {
    this.bootstrap();
  }

  private async bootstrap() {
    await configure({
      appId: environment.linkMeAppId,
      appKey: environment.linkMeAppReadKey,
    });
    const initial = await resolveFromUrl();
    if (initial?.path) {
      void this.router.navigateByUrl(initial.path);
    }
    this.teardown = onLink((payload) => {
      if (payload?.path) void this.router.navigateByUrl(payload.path);
    }).remove;
  }

  ngOnDestroy() {
    this.teardown?.();
  }
}

Inject LinkMeService in your root component (e.g. AppComponent) to activate the SDK.

API Reference

configure(config)

Initialize the SDK with your configuration.

await configure({
  appId: '<APP_ID>',
  appKey: '<APP_KEY>',
  autoResolve: true,           // Optional (default: true in browser)
  autoListen: true,            // Optional (default: true in browser)
  stripCid: true,              // Optional (default: true)
  sendDeviceInfo: true,        // Optional (default: true)
  resolveUniversalLinks: true, // Optional (default: true)
  fetch: customFetch,          // Optional (default: globalThis.fetch)
});

resolveFromUrl(url?)

Resolve a link from the current URL (or a provided URL).

const payload = await resolveFromUrl();
// or
const payload = await resolveFromUrl('https://example.com?cid=abc123');

handleLink(url)

Manually resolve an arbitrary URL string.

const payload = await handleLink('https://example.com?cid=abc123');

onLink(callback)

Listen for deep link payloads.

const subscription = onLink((payload: LinkMePayload) => {
  console.log('Link received:', payload);
  // Navigate based on payload.path
});

// Clean up when done
subscription.remove();

claimDeferredIfAvailable()

Claim a deferred deep link (probabilistic fallback for first launch).

const payload = await claimDeferredIfAvailable();
if (payload?.path) {
  router.push(payload.path);
}

track(event, properties?)

Track custom events.

await track('open', { screen: 'home' });
await track('purchase', { amount: 99.99, currency: 'USD' });

setUserId(userId)

Associate a user ID with the session.

setUserId('user-123');

getLastPayload()

Get the most recent resolved payload (if any).

const lastPayload = getLastPayload();

Payload Structure

type LinkMePayload = {
  linkId?: string;                    // Unique link identifier
  path?: string;                      // Deep link path (e.g., "profile")
  params?: Record<string, string>;    // Query parameters
  utm?: Record<string, string>;       // UTM parameters
  custom?: Record<string, string>;    // Custom data
  cid?: string;                       // Click token identifier
  duplicate?: boolean;                // Whether this was a duplicate claim
};

Configuration options

Option Type Default Description
appId string undefined Optional app identifier. Sent via x-app-id header.
appKey string undefined Optional app key. Use a read-only key when enforced.
fetch typeof fetch globalThis.fetch Provide your own fetch for environments that need a polyfill.
autoResolve boolean true in the browser Resolve window.location.href immediately after configure.
autoListen boolean true in the browser Subscribe to popstate/hashchange to pick up navigation changes.
stripCid boolean true Removes cid from the address bar after it has been resolved.
sendDeviceInfo boolean true Sends basic locale/UA/screen metadata when resolving links.
resolveUniversalLinks boolean true POSTs to /api/deeplink/resolve-url when no cid is present but the URL matches your domain.

Optional: Class-based API

For dependency injection or testing, use the class-based API:

import { LinkMeWebClient } from '@li-nk.me/web-sdk';

const client = new LinkMeWebClient();

await client.configure({
  appId: '<APP_ID>',
  appKey: '<APP_KEY>',
});

const initial = await client.resolveFromUrl();
const sub = client.onLink((payload) => {});
await client.claimDeferredIfAvailable();
client.setUserId('user-123');
await client.track('open', { screen: 'home' });
sub.remove();

Security notes

  • When keys are required, issue a read-only key for browser usage. The SDK only calls read endpoints (GET /api/deeplink, POST /api/deeplink/resolve-url, POST /api/deferred/claim, POST /api/app-events).
  • Consider rate limiting public usage if you expose the SDK on public marketing pages.
  • The SDK strips cid parameters from the address bar after they are resolved to avoid leaking tokens via referrers.

Troubleshooting

  • Verify cid query parameters make it through your router. If the parameter is removed before configure, pass the full URL into resolveFromUrl(urlString).
  • Use the browser devtools network tab to inspect calls to /api/deeplink and /api/deferred/claim.
  • If using SSR frameworks, ensure configure is only called on the client side (use useEffect or similar lifecycle hooks).