@lsp-indexer/react playground

@lsp-indexer/next

Next.js server actions and query hooks — the same data-fetching API as @lsp-indexer/react, but data flows through the server. The browser never sees your Hasura endpoint. Subscriptions use @lsp-indexer/react hooks (see why).

npm install @lsp-indexer/next @tanstack/react-query

Environment Setup

Server-side env vars (never exposed to the browser):

# .env.local
INDEXER_URL=http://localhost:8080/v1/graphql
# Upstream Hasura WS for the proxy (falls back to INDEXER_URL with ws://)
# INDEXER_WS_URL=ws://localhost:8080/v1/graphql
# Required for WS proxy CORS validation (dev and production)
INDEXER_ALLOWED_ORIGINS=http://localhost:3000

Client-side env var for subscriptions:

# Browser connects to the WS proxy at this URL (not directly to Hasura)
NEXT_PUBLIC_INDEXER_WS_URL=ws://localhost:4000

Two WS URLs: NEXT_PUBLIC_INDEXER_WS_URL is where the browser connects (the WS proxy). INDEXER_WS_URL is where the proxy connects (upstream Hasura). The browser never sees the Hasura URL.

Note: Server-side variables are read at runtime via process.env. Do not add them to the env block in next.config.ts — that would inline their values into the client bundle.


How It Differs from @lsp-indexer/react

@lsp-indexer/react@lsp-indexer/next
Data pathBrowser → HasuraBrowser → Server Action → Hasura
Env varsNEXT_PUBLIC_INDEXER_URLINDEXER_URL
Hasura URL exposureVisible in browserHidden on server
Hook APISameSame
SubscriptionsDirect WebSocketUse @lsp-indexer/react with WS proxy
Server componentsNoServer actions via 'use server'
Access controlHasura permissions onlyServer-side middleware possible

Provider Setup

@lsp-indexer/next provides server actions and query hooks — it does not include subscription hooks or a subscription provider. Subscriptions always use @lsp-indexer/react (see why below).

To use both server actions and subscriptions, set up the React subscription provider. Set NEXT_PUBLIC_INDEXER_WS_URL to your WS proxy URL so the upstream Hasura URL stays hidden from the browser:

# .env.local
NEXT_PUBLIC_INDEXER_WS_URL=wss://your-ws-proxy.example.com/v1/graphql
// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { IndexerSubscriptionProvider } from '@lsp-indexer/react';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: { queries: { staleTime: 60_000 } },
      }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <IndexerSubscriptionProvider>{children}</IndexerSubscriptionProvider>
    </QueryClientProvider>
  );
}

The provider reads NEXT_PUBLIC_INDEXER_WS_URL automatically. Point it at your WS proxy to keep the real Hasura URL hidden from the browser.


Using Hooks

The hook API is identical to @lsp-indexer/react. Simply swap the import:

// Before (client-side)
import { useProfile } from '@lsp-indexer/react';

// After (server-side)
import { useProfile } from '@lsp-indexer/next';

Everything else stays the same — filters, sorting, include fields, infinite scroll.

import { useProfile, useDigitalAssets, useInfiniteNfts } from '@lsp-indexer/next';

function MyComponent() {
  const { profile } = useProfile({ address: '0x...' });
  const { digitalAssets } = useDigitalAssets({ filter: { tokenType: 'NFT' }, limit: 10 });
  const { nfts, fetchNextPage } = useInfiniteNfts({ pageSize: 20 });
  // ...
}

Server Actions

Under the hood, each hook calls a Next.js server action. Server actions are exported from a separate entry point (@lsp-indexer/next/actions) to ensure they never leak into the client bundle. You can also use them directly in Server Components or Route Handlers:

// In a Server Component
import { getProfile, getDigitalAssets } from '@lsp-indexer/next/actions';

export default async function Page() {
  const profile = await getProfile({ address: '0x...' });
  const { digitalAssets } = await getDigitalAssets({
    filter: { tokenType: 'TOKEN' },
    limit: 5,
  });

  return (
    <div>
      <h1>{profile?.name}</h1>
      <ul>
        {digitalAssets.map((a) => (
          <li key={a.address}>{a.name}</li>
        ))}
      </ul>
    </div>
  );
}

Available server actions

Every domain has server actions matching the fetch functions:

DomainActions
ProfilesgetProfile, getProfiles
Digital AssetsgetDigitalAsset, getDigitalAssets
NFTsgetNft, getNfts
Owned AssetsgetOwnedAsset, getOwnedAssets
Owned TokensgetOwnedToken, getOwnedTokens
CreatorsgetCreators
Issued AssetsgetIssuedAssets
FollowsgetFollows, getFollowCount, getIsFollowing, getIsFollowingBatch, getMutualFollows, getMutualFollowers, getFollowedByMyFollows
Encrypted AssetsgetEncryptedAssets, getEncryptedAssetsBatch
Data ChangedgetDataChangedEvents, getLatestDataChangedEvent
Token ID Data ChangedgetTokenIdDataChangedEvents, getLatestTokenIdDataChangedEvent
Universal ReceivergetUniversalReceiverEvents
Collection AttributesgetCollectionAttributes

Batch Follow Checking

useIsFollowingBatch checks multiple follower→followed pairs in a single query via the getIsFollowingBatch server action. Returns a Map<string, boolean> keyed by "followerAddress:followedAddress".

import { useIsFollowingBatch } from '@lsp-indexer/next';

const pairs = [
  { followerAddress: '0xFollower1', followedAddress: '0xFollowed1' },
  { followerAddress: '0xFollower2', followedAddress: '0xFollowed2' },
];

const { results, isLoading, error } = useIsFollowingBatch({ pairs });
// Keys are lowercased — any address casing is accepted as input:
// results.get('0xfollower1:0xfollowed1') → true | false

The server action (getIsFollowingBatch) serializes the Map as a Record<string, boolean> over the wire. The hook reconstructs the Map on the client. The Hasura URL stays hidden from the browser.


Batch Encrypted Asset Fetch

useEncryptedAssetsBatch fetches multiple encrypted assets by (address, contentId, revision) tuples in a single query via the getEncryptedAssetsBatch server action.

import { useEncryptedAssetsBatch } from '@lsp-indexer/next';

const tuples = [
  { address: '0xAssetAddress1', contentId: 'content-1', revision: 1 },
  { address: '0xAssetAddress2', contentId: 'content-2', revision: 0 },
];

const { encryptedAssets, isLoading, error } = useEncryptedAssetsBatch({
  tuples,
  include: { encryption: true },
});
// encryptedAssets → EncryptedAsset[] (one per matched tuple)

The server action (getEncryptedAssetsBatch) validates input via Zod and fetches from Hasura server-side. The Hasura URL stays hidden from the browser. Empty tuples short-circuits — no query is fired. If no tuples match, encryptedAssets returns [] — no error is thrown. Address matching is case-insensitive. Duplicate tuples are not deduplicated — pass unique tuples.


Mutual Follow Queries

@lsp-indexer/next provides 6 mutual follow hooks that mirror the @lsp-indexer/react API — data flows through server actions so the Hasura URL stays hidden from the browser.

HookServer ActionDescription
useMutualFollowsgetMutualFollowsProfiles that both addressA and addressB follow
useInfiniteMutualFollowsgetMutualFollowsInfinite-scroll variant
useMutualFollowersgetMutualFollowersProfiles that follow both addressA and addressB
useInfiniteMutualFollowersgetMutualFollowersInfinite-scroll variant
useFollowedByMyFollowsgetFollowedByMyFollowsProfiles that myAddress follows and that also follow targetAddress
useInfiniteFollowedByMyFollowsgetFollowedByMyFollowsInfinite-scroll variant

Usage

The hook API is identical to @lsp-indexer/react — swap the import:

import { useMutualFollows } from '@lsp-indexer/next';
import type { ProfileInclude } from '@lsp-indexer/types';

const include: ProfileInclude = { ownedAssets: true };

function MutualFollows({ addressA, addressB }: { addressA: string; addressB: string }) {
  const { profiles, totalCount, isLoading } = useMutualFollows({
    addressA,
    addressB,
    sort: { field: 'name', direction: 'asc' },
    limit: 10,
    include,
  });

  return (
    <div>
      <p>{totalCount} mutual follows</p>
      {profiles?.map((p) => <div key={p.address}>{p.name}</div>)}
    </div>
  );
}

Server Actions in Server Components

import { getMutualFollows, getFollowedByMyFollows } from '@lsp-indexer/next/actions';

export default async function Page() {
  const { profiles } = await getMutualFollows({ addressA: '0x...', addressB: '0x...', limit: 10 });
  const { profiles: suggested } = await getFollowedByMyFollows({
    myAddress: '0x...',
    targetAddress: '0x...',
    limit: 5,
  });

  return (
    <ul>
      {profiles.map((p) => (
        <li key={p.address}>{p.name}</li>
      ))}
    </ul>
  );
}

Collection Attributes

useCollectionAttributes fetches distinct {key, value} attribute pairs for a collection via the getCollectionAttributes server action. The Hasura URL stays hidden from the browser.

Hook Usage

import { useCollectionAttributes } from '@lsp-indexer/next';

function TraitFilter({ collectionAddress }: { collectionAddress: string }) {
  const { attributes, totalCount, isLoading, error } = useCollectionAttributes({
    collectionAddress,
  });

  if (isLoading) return <p>Loading traits…</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <p>{totalCount} NFTs in collection</p>
      <ul>
        {attributes?.map((attr) => (
          <li key={`${attr.key}:${attr.value}`}>
            {attr.key}: {attr.value}
          </li>
        ))}
      </ul>
    </div>
  );
}

Server Action in Server Components

import { getCollectionAttributes } from '@lsp-indexer/next/actions';

export default async function CollectionPage({ address }: { address: string }) {
  const { attributes, totalCount } = await getCollectionAttributes(address);

  return (
    <div>
      <p>{totalCount} NFTs</p>
      <ul>
        {attributes.map((attr) => (
          <li key={`${attr.key}:${attr.value}`}>
            {attr.key}: {attr.value}
          </li>
        ))}
      </ul>
    </div>
  );
}

NFT Include, Filter, and Sort Fields

NFTs support chillwhales-specific include fields, filters, and sort options. These apply to both the hook API (useNfts, useInfiniteNfts, useNft) and the server actions (getNfts, getNft).

NFT Include Fields

Opt in to chillwhales-specific fields to reduce payload size:

import { useNfts } from '@lsp-indexer/next';
import type { NftInclude } from '@lsp-indexer/types';

const include: NftInclude = {
  score: true,
  rank: true,
  chillClaimed: true,
  orbsClaimed: true,
  level: true,
  cooldownExpiry: true,
  faction: true,
};

const { nfts } = useNfts({ filter: { collectionAddress: '0x...' }, include });

All NFT include fields: formattedTokenId, name, description, category, icons, images, links, attributes, timestamp, blockNumber, transactionIndex, logIndex, score, rank, chillClaimed, orbsClaimed, level, cooldownExpiry, faction. Relations: collection (boolean | DigitalAssetInclude), holder (boolean | ProfileInclude).

NFT Filters

import type { NftFilter } from '@lsp-indexer/types';

const filter: NftFilter = {
  collectionAddress: '0x...',
  chillClaimed: false,
  orbsClaimed: false,
  maxLevel: 5,
  cooldownExpiryBefore: Math.floor(Date.now() / 1000),
};

Filter fields: collectionAddress, tokenId, formattedTokenId, name, holderAddress, isBurned, isMinted, chillClaimed, orbsClaimed, maxLevel, cooldownExpiryBefore.

NFT Sort Fields

import type { NftSort } from '@lsp-indexer/types';

const sort: NftSort = { field: 'score', direction: 'desc', nulls: 'last' };

Sort fields: newest, oldest, tokenId, formattedTokenId, score. newest/oldest use deterministic block-order; direction/nulls are ignored for those values.


Why Subscriptions Use @lsp-indexer/react

Next.js does not support WebSocket connections in API routes — serverless functions are short-lived and stateless, so they cannot hold a persistent WebSocket connection open. Because of this, all subscription hooks live in @lsp-indexer/react, not in @lsp-indexer/next.

When you need real-time data in a Next.js app while keeping the Hasura URL hidden, use the @lsp-indexer/react subscription hooks pointed at a WS proxy (provided by @lsp-indexer/next/server). The proxy runs as a standalone process on port 4000 and forwards WebSocket frames to Hasura.

Browser → @lsp-indexer/react → WS Proxy (port 4000) → Hasura WebSocket

Setting Up the Proxy

Create a standalone proxy server script:

// ws-proxy.ts
import { createProxyServer } from '@lsp-indexer/next/server';

const { server } = createProxyServer({
  allowedOrigins: process.env.INDEXER_ALLOWED_ORIGINS?.split(','),
  maxConnections: 100,
  maxPayload: 64 * 1024,
});

server.listen(4000, () => {
  console.log('WS proxy listening on port 4000');
});

Proxy Features

  • Origin validation — only allows connections from INDEXER_ALLOWED_ORIGINS
  • Connection limits — configurable max concurrent connections
  • Payload limits — configurable max WebSocket frame size
  • Auto-reconnect — reconnects to upstream Hasura on connection loss
  • Per-client isolation — each browser client gets its own upstream connection

Environment Variables

VariableRequiredDescription
INDEXER_WS_URLIf no INDEXER_URLWebSocket endpoint for Hasura
INDEXER_URLFallbackHTTP URL, auto-derived to wss://
INDEXER_ALLOWED_ORIGINSDev + ProductionComma-separated allowed origins

Deployment

Vercel / Serverless

Server actions work out of the box on Vercel. Subscriptions use @lsp-indexer/react hooks, so you need a separate long-running process for the WS proxy (Vercel functions are short-lived).

Options:

  • Deploy the WS proxy on a VPS or container alongside your Hasura instance
  • Use a managed WebSocket service
  • Point @lsp-indexer/react directly at Hasura (exposes the Hasura URL to the browser)

Self-hosted

When self-hosting, run the WS proxy alongside your Next.js app:

# Start Next.js
next start

# Start WS proxy (separate process)
node ws-proxy.js

Or use Docker Compose to run both:

services:
  app:
    build: .
    ports: ['3000:3000']
    environment:
      INDEXER_URL: http://hasura:8080/v1/graphql
      # Browser connects to the WS proxy — use the publicly accessible URL
      NEXT_PUBLIC_INDEXER_WS_URL: ws://localhost:4000
  ws-proxy:
    build: .
    command: node ws-proxy.js
    ports: ['4000:4000']
    environment:
      INDEXER_URL: http://hasura:8080/v1/graphql
      INDEXER_ALLOWED_ORIGINS: http://localhost:3000

Choosing Between React and Next

Use @lsp-indexer/react when:

  • You want the simplest setup (no server needed)
  • Your Hasura endpoint is public or protected by Hasura's own permissions
  • You need direct WebSocket subscriptions without a proxy

Use @lsp-indexer/next when:

  • You want to hide the Hasura URL from the browser
  • You need server-side rendering or server components
  • You want to add server-side middleware (auth, rate limiting, logging)
  • You're already using Next.js

Note: Subscriptions always use @lsp-indexer/react hooks regardless of which package you use for data fetching. When using @lsp-indexer/next, point the React subscription provider at the WS proxy to keep the Hasura URL hidden (see Provider Setup).


Next Steps