Key takeaways

  • Sign the offerwall link in a server-only module called from a Server Component or Route Handler, and never give the salt a NEXT_PUBLIC_ or VITE_ prefix.
  • In React, put data-sharklio-open on your own link with the signed wall link as its href, and prefer it over the floating button when users can change without a page load.
  • Credit coins only in a postback Route Handler that checks the HMAC with timingSafeEqual and lets the database apply each transaction ID once.

How do you add an offerwall to a Next.js or React app?

Build the signed offerwall link on the server (in a Server Component or a Route Handler in Next.js, or in your API for a plain React app), pass only the finished URL to the browser, and show it as a link, an iframe, or a pop-up opened by the Sharklio embed script. Then receive the postback in a Route Handler that recomputes the HMAC-SHA256 signature, compares it in constant time, and credits each transaction ID only once. The link hash salt and the postback secret key never appear in client code, and React never adds currency on its own.

This guide focuses on the React side: where to sign the link, how to show the wall, a JSX attribute trap, and testing on localhost. The postback handler is here in TypeScript too, but the full reasoning behind every check lives in Offerwall postback in Node.js, Express and Next.js, so this page links there instead of repeating it. All code was compiled and run against Next.js 16.4 and React 19.3 on October 22, 2026.

Who does what

PartDoesNever does
Server Component, Route Handler, or your APISigns the link for the signed-in user, verifies postbacks, owns the balanceSigns a link for a user ID that came from the browser
Client Component or React SPAShows the link, iframe, or button, and reloads the balanceHolds the salt or the postback key, adds coins
SharklioShows offers for the user’s country and device, tracks results, sends a signed postback for every credit and every reversalPays your users directly

From your app’s Integration tab you need three values: the app ID in your offerwall link, the link hash salt (signs links), and the postback secret key (signs postbacks). They are different secrets. If you have not created the app yet, start with How to create your first app.

The Sharklio Offerwall link docs define the format:

https://wall.sharklio.com/{APP_ID}?user_id={USER_ID}&hash={HASH}

The hash is the HMAC-SHA256 of the user ID with your link hash salt, as lowercase hex. The user ID is 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -, must never change for the same user, and should be your database ID, not an email or a username. You can add up to five sub IDs (sub1 to sub5, 1 to 100 characters from A-Z a-z 0-9 . _ ~ : @ + -) to see which placement earns. They are not part of the hash, so use them for tracking only. If HMAC is new to you, What is HMAC? explains it in plain terms.

// lib/sharklio.ts
import 'server-only';
import crypto from 'node:crypto';

const USER_ID = /^[A-Za-z0-9._@:+-]{1,100}$/;
const SUB_VALUE = /^[A-Za-z0-9._~:@+-]{1,100}$/;
type SubKey = 'sub1' | 'sub2' | 'sub3' | 'sub4' | 'sub5';

function env(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is not set`);
  return value;
}

export function wallUrl(userId: string | number, subs: Partial<Record<SubKey, string>> = {}): string {
  const id = String(userId);
  if (!USER_ID.test(id)) throw new Error('user ID not accepted by the offerwall');
  const hash = crypto.createHmac('sha256', env('SHARKLIO_LINK_SALT')).update(id).digest('hex');
  let url = `https://wall.sharklio.com/${env('SHARKLIO_APP_ID')}?user_id=${encodeURIComponent(id)}&hash=${hash}`;
  for (const [key, value] of Object.entries(subs)) {
    if (value && SUB_VALUE.test(value)) url += `&${key}=${encodeURIComponent(value)}`;
  }
  return url;
}

Three details keep the secret where it belongs:

  • No public prefix. The Next.js environment variables guide says variables without NEXT_PUBLIC_ are only available on the server, while prefixed ones are inlined into the JavaScript sent to the browser. Vite works the same way with VITE_: its env and mode guide says those variables end up in client code and should not hold API keys. Name the salt SHARKLIO_LINK_SALT, never NEXT_PUBLIC_SHARKLIO_LINK_SALT or VITE_SHARKLIO_LINK_SALT.
  • server-only. The Next.js page on Server and Client Components says that importing a module marked with import 'server-only' into a Client Component causes a build-time error. A teammate who later imports wallUrl into a 'use client' file gets a failed build instead of a leaked salt.
  • Keys read lazily. env() runs when a link is built, not at import time, so next build works on a machine without the secrets, and a server missing a key fails loudly instead of signing with an empty string.

Step 2: choose how to show the wall

Every Sharklio app can use all of these at once, with the same keys and postback URL. The trade-offs between a web wall and building your own are in Offerwall integration: iframe, link or API?

OptionReact code you writeWhere it worksBest for
Plain linkAn <a href> with the signed URLAnywhere, including localhost and WebViewsMenus, emails from your app, mobile WebViews
Iframe on an Earn pageOne small componenthttps pages of your registered website and its subdomainsRewards sites where the wall is the main product
Floating Earn buttonOne next/script tag in a layoutSame as the iframeServer-rendered Next.js sites with full page loads at sign-in and sign-out
Your own button with the pop-upA link with data-sharklio-open="", or Sharklio.open(url)Same as the iframe; falls back to the plain linkSPAs and apps where the user can change without a page load
Offers APIYour own offer list UIAnywhereA fully native look; see Build a custom offerwall with the Offers API

The iframe component

App Router pages are Server Components by default, so the page can call wallUrl directly and hand the result to a plain component:

// components/offerwall-frame.tsx
type Props = { src: string; title?: string; height?: string };

export function OfferwallFrame({ src, title = 'Offerwall', height = '100vh' }: Props) {
  return (
    <iframe
      key={src}
      title={title}
      src={src}
      style={{ border: 0, width: '100%', height }}
    />
  );
}
// app/earn/page.tsx
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { wallUrl } from '@/lib/sharklio';
import { OfferwallFrame } from '@/components/offerwall-frame';
import { RefreshOnReturn } from '@/components/refresh-on-return';

export default async function EarnPage() {
  const user = await getCurrentUser();
  if (!user) redirect('/login');

  return (
    <main>
      <h1>Earn coins</h1>
      <p>Coins usually arrive a few minutes after an offer is confirmed.</p>
      <OfferwallFrame src={wallUrl(user.id, { sub1: 'earn_page' })} height="calc(100vh - 64px)" />
      <RefreshOnReturn />
    </main>
  );
}

getCurrentUser stands for your auth library’s session lookup. Because it reads the session cookie, Next.js renders the page per request, so each user gets their own link; our build listed /earn as dynamic. Keep the inline style: the docs note that without it, browsers draw the frame as a 300 by 150 pixel box. The key makes React replace the frame if the link changes, and RefreshOnReturn comes in Step 5.

If you send a Content Security Policy, allow https://wall.sharklio.com in frame-src for the iframe and the pop-up, and in script-src for the embed script.

The floating Earn button with next/script

The embed script draws a button in the corner and opens the wall in a pop-up over your page. The Next.js scripts guide says extra attributes such as custom data- attributes are forwarded to the final script element, so the documented attributes work as props:

// app/layout.tsx
import Script from 'next/script';
import { getCurrentUser } from '@/lib/auth';
import { wallUrl } from '@/lib/sharklio';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser();
  return (
    <html lang="en">
      <body>
        {children}
        {user && (
          <Script
            src="https://wall.sharklio.com/embed.js"
            data-button-url={wallUrl(user.id, { sub1: 'button' })}
            data-button-text="Earn coins"
            data-button-position="right"
          />
        )}
      </body>
    </html>
  );
}

In a headless Chrome test, this rendered the button with the signed link and opened the pop-up on click. One limit matters for React apps: the same guide says Next.js loads a script only once, even as the user navigates, and the button reads its link when the script runs. If a user signs out and another signs in without a full page load, the button still holds the first user’s link. Use it only when sign-in and sign-out reload the page, or use your own button below. The floating Earn button guide covers labels, colors, and placement rules.

Your own button, and the JSX attribute trap

The embed script also opens the pop-up for any link that carries data-sharklio-open, and it listens for clicks on the whole document, so links React renders later work too. Load the script once (in the root layout with <Script src="https://wall.sharklio.com/embed.js" />, or in index.html for a Vite app), then:

// components/earn-link.tsx
export function EarnLink({ href }: { href: string }) {
  return (
    <a href={href} data-sharklio-open="">
      Earn coins
    </a>
  );
}

Bare attribute or empty string both work

In JSX, a bare data-sharklio-open means true, and React 19 renders it as data-sharklio-open="true". The embed script treats "true" and an empty value the same way and opens the link’s href. Writing data-sharklio-open="" keeps the markup explicit, and the href still works as a plain link if the script is blocked. If a click does nothing, check the console: the script logs a warning when the link does not start with https://wall.sharklio.com/.

For a click handler, for example a “Not enough coins?” button in your shop, call the documented Sharklio.open(link) with a fallback for the moment before the script has loaded:

declare global {
  interface Window {
    Sharklio?: { open(link: string): void };
  }
}

export function openOfferwall(url: string) {
  if (window.Sharklio?.open) {
    window.Sharklio.open(url);
  } else {
    window.location.href = url;
  }
}

Call it only from a user’s click. Opening the wall on page load, on a timer, or on scroll breaks the placement rules.

Step 3: a plain React app (Vite)

Without a framework, the browser has to ask your backend for the link. In Next.js, a Route Handler does that for Client Components that need it; for a separate backend, the Express version is in the Node.js postback guide.

// app/api/offerwall-link/route.ts
import { getCurrentUser } from '@/lib/auth';
import { wallUrl } from '@/lib/sharklio';

export async function GET() {
  const user = await getCurrentUser();
  if (!user) return Response.json({ error: 'not signed in' }, { status: 401 });
  return Response.json(
    { url: wallUrl(user.id, { sub1: 'spa' }) },
    { headers: { 'Cache-Control': 'private, no-store' } }
  );
}

The user comes from the session cookie, never from a query parameter, and no-store stops any cache from giving one user’s link to another. On the React side, a small hook fetches the link whenever the signed-in user changes:

// src/useOfferwallLink.ts
import { useEffect, useState } from 'react';

type Link = { userId: string; url: string };

export function useOfferwallLink(userId: string | null): string | null {
  const [link, setLink] = useState<Link | null>(null);

  useEffect(() => {
    if (!userId) return;
    let ignore = false;
    fetch('/api/offerwall-link', { credentials: 'same-origin', cache: 'no-store' })
      .then((res) => (res.ok ? res.json() : null))
      .then((data) => {
        const url = data?.url;
        if (!ignore && typeof url === 'string' && url.startsWith('https://wall.sharklio.com/')) {
          setLink({ userId, url });
        }
      })
      .catch(() => {});
    return () => {
      ignore = true;
    };
  }, [userId]);

  return link && link.userId === userId ? link.url : null;
}

The ignore flag is the pattern from React’s useEffect reference: responses can arrive out of order, and the cleanup discards a stale one. Storing the user ID with the URL means a link fetched for the previous user is never returned for the next one. If you use TanStack Query or SWR, put the same fetch in a query keyed by the user ID. Render it with the EarnLink above, or pass the URL to OfferwallFrame.

Step 4: the postback Route Handler

Rewards never travel through React. Sharklio calls your server once per event, and the Postbacks docs define the signature: HMAC-SHA256 of transaction_id:user_id:reward:status with your postback secret key, as lowercase hex, using the values exactly as they arrive. Register this URL in the Postback tab:

https://yoursite.com/api/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}

Add the reading and checking functions to lib/sharklio.ts:

// lib/sharklio.ts (continued)
export type Postback = { tx: string; user: string; reward: string; status: string; hash: string };
const KEYS = ['tx', 'user', 'reward', 'status', 'hash'] as const;

export function readPostback(params: URLSearchParams): Postback | null {
  const p = {} as Postback;
  for (const key of KEYS) {
    const values = params.getAll(key);
    if (values.length !== 1) return null;
    p[key] = values[0];
  }
  return p;
}

export function validHash(p: Postback): boolean {
  const expected = Buffer.from(
    crypto.createHmac('sha256', env('SHARKLIO_POSTBACK_KEY'))
      .update(`${p.tx}:${p.user}:${p.reward}:${p.status}`)
      .digest('hex')
  );
  const given = Buffer.from(p.hash);
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

The Next.js route.js reference says Route Handlers use the Web Request and Response APIs, so the handler stays short:

// app/api/sharklio/postback/route.ts
import { readPostback, validHash } from '@/lib/sharklio';
import { applyEvent } from '@/lib/credit';

export const runtime = 'nodejs';

export async function GET(request: Request) {
  try {
    const p = readPostback(new URL(request.url).searchParams);
    if (!p || !validHash(p)) return new Response(null, { status: 403 });
    if (p.tx.startsWith('test_')) {
      console.info('sharklio: test postback', p.tx, p.status);
      return new Response(null, { status: 200 });
    }
    await applyEvent(p);
    return new Response(null, { status: 200 });
  } catch (err) {
    console.error('sharklio: postback failed', err);
    return new Response(null, { status: 500 });
  }
}

Crediting each transaction once

A retry can deliver the same event twice, and a reversal (status 2) carries the same transaction_id and the same positive reward as the credit it cancels. This version lets the primary key decide: the PostgreSQL INSERT reference says ON CONFLICT DO NOTHING simply skips the row, and the row count only includes rows actually inserted. It assumes the sharklio_tx table from the Node.js guide with last_status set on every row, and a pg pool exported from lib/db.ts:

// lib/credit.ts
import 'server-only';
import { pool } from '@/lib/db';
import type { Postback } from '@/lib/sharklio';

const INSERT = `INSERT INTO sharklio_tx (transaction_id, user_id, reward, last_status)
  VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO NOTHING`;

export async function applyEvent(p: Postback): Promise<void> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    if (p.status === '1') {
      const created = await client.query(INSERT, [p.tx, p.user, p.reward, 1]);
      if (created.rowCount === 1) {
        const r = await client.query('UPDATE users SET coins = coins + $1::numeric WHERE id = $2', [p.reward, p.user]);
        if (r.rowCount === 0) console.warn('sharklio: unknown user', p.user, p.tx);
      }
    } else if (p.status === '2') {
      const reversed = await client.query(
        `UPDATE sharklio_tx SET last_status = 2, updated_at = now()
         WHERE transaction_id = $1 AND last_status = 1 RETURNING user_id, reward`,
        [p.tx]
      );
      if (reversed.rowCount === 1) {
        const { user_id, reward } = reversed.rows[0];
        await client.query('UPDATE users SET coins = coins - $1 WHERE id = $2', [reward, user_id]);
      } else {
        await client.query(INSERT, [p.tx, p.user, p.reward, 2]);
      }
    }
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

We ran it against a real PostgreSQL engine (PGlite): a credit sent twice paid once, a reversal sent twice took the coins back once, a credit arriving after its reversal paid nothing, and a reversal for a never-credited transaction was recorded so a late credit could not pay. The design choices and other databases are covered in How to prevent duplicate reward credits.

Next.js details that break postbacks:

  • Redirects. Sharklio does not follow redirects. Register the exact path your trailingSlash setting serves, and exclude the route from any proxy (formerly middleware) that sends signed-out visitors to a login page.
  • Six seconds. Answer any 2xx within 6 seconds, or the event is retried up to 5 times. On serverless hosting, a cold start plus a new database connection eats into that, so use your provider’s connection pooler and queue emails until after the commit.
  • Status codes. 200 for anything applied or deliberately skipped, 403 for a bad hash, 500 for your own failures so the event comes back later. Our run returned 500 with a missing key, 403 for a changed hash or a repeated tx parameter.
  • Old Next.js versions. The route.js reference notes that GET handlers became dynamic by default in version 15. On 14 or earlier, add export const dynamic = 'force-dynamic'.

Why each check exists is explained in Postback security: verify every reward call.

Step 5: show the new balance in React

Credits arrive when the advertiser confirms a result, sometimes within minutes, sometimes later, so React should read the balance from your server. In the App Router, a tiny Client Component refreshes the server-rendered page when the user comes back to the tab:

// components/refresh-on-return.tsx
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';

export function RefreshOnReturn() {
  const router = useRouter();
  useEffect(() => {
    function onVisible() {
      if (document.visibilityState === 'visible') router.refresh();
    }
    document.addEventListener('visibilitychange', onVisible);
    return () => document.removeEventListener('visibilitychange', onVisible);
  }, [router]);
  return null;
}

In a Vite app, refetch your balance query on the same event, or turn on focus refetching in your data library. Show a short reward history from your status 1 rows, so users can see what arrived and when. Users follow pending offers in My offers inside the wall and can report a missing reward there. If you price items in coins, the virtual currency calculator helps you set the rate.

Test it on localhost and before going live

The iframe and the pop-up only load on https pages of the website you registered for the app and its subdomains, so on http://localhost:3000 they stay blank. That is expected, not a bug. While you develop:

  1. Open the signed link itself in a new tab: the plain link works anywhere. Or click Open as a test user in Integration status to see your wall without a link.
  2. Use Check a link and its Test a link tool on the Integration tab. Type the user ID your code signs and compare: the two links must be identical.
  3. Test the pop-up and iframe on an https staging subdomain of your registered site, for example staging.yoursite.com.
  4. Expose your local postback route through a tunnel, or test it with a hash you compute yourself (sample values only):
    TX=local_1 U=42 R=150 S=1
    HASH=$(node -e "console.log(require('node:crypto').createHmac('sha256', process.env.SHARKLIO_POSTBACK_KEY).update(process.argv[1]).digest('hex'))" "$TX:$U:$R:$S")
    curl -i "http://localhost:3000/api/sharklio/postback?tx=$TX&user=$U&reward=$R&status=$S&hash=$HASH"
    Run it twice and user 42 gains 150 coins once.
  5. Click Send a test postback once with status 1 and once with status 2. Test transaction IDs start with test_, which the handler logs without crediting.
  6. Test from a normal home or mobile connection: VPNs, proxies, Tor and data center networks see a block page instead of offers.

The full checklist is in How to test your app before it goes live and the Sharklio Testing docs. After launch, every postback attempt and your server’s answer is in Reports, Postbacks; How to resend a failed postback covers the rest.

A quick review before you deploy

Search the built client bundle (.next/static or dist) for the first characters of your salt and postback key: there must be no match. Check that no page with a signed link is statically cached. Confirm the postback path answers 403, not a redirect, when you open it in a browser without parameters. If a key ever leaks, follow How to rotate your keys and link salt.

Sharklio in a Next.js or React app

Sharklio is a pay-per-result affiliate network: your users earn your own currency by completing offers, and you earn per completed offer. A web app can use the signed offerwall link or iframe, the floating Earn button (embed.js), or the Offers API, and a React Native app has its own package (see React Native offerwall). Each app sets its currency name and icon, conversion rate, user split, rounding, and wall design with a live preview, and every credit and reversal reaches your server as a signed postback. Every publisher application is reviewed by hand. See how the wall works, or get in touch.

Frequently asked questions

Can I build the offerwall link in a React component?

Only in a Server Component or on your backend. Anything in client JavaScript, including an environment variable with the NEXT_PUBLIC_ or VITE_ prefix, can be read by users, and the salt would let anyone open the wall as any user ID.

Why is my offerwall iframe blank on localhost?

The iframe and the pop-up only load on https pages of the website registered for the app and its subdomains. Open the signed link directly in a tab while developing, and test the embed on an https staging subdomain.

How do I receive an offerwall postback in Next.js?

Create app/api/sharklio/postback/route.ts exporting GET, read each parameter as exactly one string, verify the HMAC-SHA256 with crypto.timingSafeEqual, and credit each transaction ID once inside a database transaction. Keep it on the Node.js runtime with no redirect in front of it.

How do I open the offerwall from my own link in React?

Put data-sharklio-open (bare or with an empty value) on a link whose href is the signed wall link from your server, and load embed.js once. If a click does nothing, check that the script loaded and that the link starts with https://wall.sharklio.com/.

Should I use the floating button or my own button in a single-page app?

Your own button. The embed script reads the floating button’s link once per page load, so it can keep a previous user’s link after a client-side sign-in, while a React-rendered link always uses the current one.

Can React credit coins when the user closes the offerwall?

No. Closing the wall says nothing about completed offers. Credit only from verified postbacks on your server, then refresh the balance in React.