@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-queryEnvironment 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:3000Client-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:4000Two WS URLs:
NEXT_PUBLIC_INDEXER_WS_URLis where the browser connects (the WS proxy).INDEXER_WS_URLis 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 theenvblock innext.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 path | Browser → Hasura | Browser → Server Action → Hasura |
| Env vars | NEXT_PUBLIC_INDEXER_URL | INDEXER_URL |
| Hasura URL exposure | Visible in browser | Hidden on server |
| Hook API | Same | Same |
| Subscriptions | Direct WebSocket | Use @lsp-indexer/react with WS proxy |
| Server components | No | Server actions via 'use server' |
| Access control | Hasura permissions only | Server-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_URLautomatically. 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:
| Domain | Actions |
|---|---|
| Profiles | getProfile, getProfiles |
| Digital Assets | getDigitalAsset, getDigitalAssets |
| NFTs | getNft, getNfts |
| Owned Assets | getOwnedAsset, getOwnedAssets |
| Owned Tokens | getOwnedToken, getOwnedTokens |
| Creators | getCreators |
| Issued Assets | getIssuedAssets |
| Follows | getFollows, getFollowCount, getIsFollowing, getIsFollowingBatch, getMutualFollows, getMutualFollowers, getFollowedByMyFollows |
| Encrypted Assets | getEncryptedAssets, getEncryptedAssetsBatch |
| Data Changed | getDataChangedEvents, getLatestDataChangedEvent |
| Token ID Data Changed | getTokenIdDataChangedEvents, getLatestTokenIdDataChangedEvent |
| Universal Receiver | getUniversalReceiverEvents |
| Collection Attributes | getCollectionAttributes |
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 | falseThe 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.
| Hook | Server Action | Description |
|---|---|---|
useMutualFollows | getMutualFollows | Profiles that both addressA and addressB follow |
useInfiniteMutualFollows | getMutualFollows | Infinite-scroll variant |
useMutualFollowers | getMutualFollowers | Profiles that follow both addressA and addressB |
useInfiniteMutualFollowers | getMutualFollowers | Infinite-scroll variant |
useFollowedByMyFollows | getFollowedByMyFollows | Profiles that myAddress follows and that also follow targetAddress |
useInfiniteFollowedByMyFollows | getFollowedByMyFollows | Infinite-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 WebSocketSetting 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
| Variable | Required | Description |
|---|---|---|
INDEXER_WS_URL | If no INDEXER_URL | WebSocket endpoint for Hasura |
INDEXER_URL | Fallback | HTTP URL, auto-derived to wss:// |
INDEXER_ALLOWED_ORIGINS | Dev + Production | Comma-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/reactdirectly 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.jsOr 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:3000Choosing 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/reacthooks 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
- @lsp-indexer/react — Client-side hooks (same API, simpler setup)
- @lsp-indexer/node — Low-level fetch functions, parsers, query keys
- Quickstart — End-to-end setup guide
- Domain Playgrounds — Try every hook live