This guide covers setting up the @hypercerts-org/sdk (core) and @hypercerts-org/sdk-react packages in a Next.js
application.
- Installation
- Prerequisites
- OAuth Configuration
- Next.js Setup
- Using Hooks
- Server-Side Rendering
- API Routes
- Complete Example
npm install @hypercerts-org/sdk @hypercerts-org/sdk-react @tanstack/react-queryBefore using the SDK, you need:
- OAuth Client Metadata - A publicly accessible JSON file describing your OAuth client
- JWKS Endpoint - A JSON Web Key Set for signing OAuth requests
- Private JWK - The private key corresponding to your JWKS (keep this secret)
Host a client-metadata.json file at a public URL (e.g., https://your-app.com/client-metadata.json):
{
"client_id": "https://your-app.com/client-metadata.json",
"client_name": "Your App Name",
"client_uri": "https://your-app.com",
"redirect_uris": ["https://your-app.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "atproto transition:generic",
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"jwks_uri": "https://your-app.com/.well-known/jwks.json"
}# .env.local
NEXT_PUBLIC_OAUTH_CLIENT_ID=https://your-app.com/client-metadata.json
NEXT_PUBLIC_OAUTH_REDIRECT_URI=https://your-app.com/callback
NEXT_PUBLIC_HANDLE_RESOLVER_URL=https://bsky.social
NEXT_PUBLIC_SDS_URL=https://sds.hypercerts.org
JWK_PRIVATE_KEY={"kty":"EC","crv":"P-256",...}Create a configuration file for your SDK setup:
// lib/atproto.ts
import { createATProtoReact } from "@hypercerts-org/sdk-react";
import { QueryClient } from "@tanstack/react-query";
// Create a shared QueryClient (can be shared with wagmi, tRPC, etc.)
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
// Create the ATProto React instance
export const atproto = createATProtoReact({
queryClient,
config: {
oauth: {
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI!,
scope: "atproto transition:generic",
},
// Optional: URL for handle resolution during OAuth (defaults to DNS-based resolution)
handleResolver: process.env.NEXT_PUBLIC_HANDLE_RESOLVER_URL || "https://bsky.social",
servers: {
sds: process.env.NEXT_PUBLIC_SDS_URL,
},
},
});
// Export hooks for convenience
export const {
Provider: ATProtoProvider,
useAuth,
useProfile,
useOrganizations,
useOrganization,
useProjects,
useProject,
useCollaborators,
useHypercerts,
useHypercert,
useRepository,
} = atproto;Create a providers component:
// app/providers.tsx
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient, ATProtoProvider } from "@/lib/atproto";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ATProtoProvider>{children}</ATProtoProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Wrap your app in the root layout:
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// pages/_app.tsx
import type { AppProps } from "next/app";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient, ATProtoProvider } from "@/lib/atproto";
export default function App({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ATProtoProvider>
<Component {...pageProps} />
</ATProtoProvider>
</QueryClientProvider>
);
}// components/AuthButton.tsx
"use client";
import { useAuth } from "@/lib/atproto";
export function AuthButton() {
const { status, session, login, logout, error } = useAuth();
if (status === "loading") {
return <button disabled>Loading...</button>;
}
if (status === "authenticated") {
return (
<div>
<span>Signed in as {session?.handle}</span>
<button onClick={() => logout()}>Sign Out</button>
</div>
);
}
return (
<div>
<button onClick={() => login("bsky.social")}>Sign in with Bluesky</button>
{error && <p className="error">{error.message}</p>}
</div>
);
}// components/Profile.tsx
"use client";
import { useProfile } from "@/lib/atproto";
export function Profile() {
const { profile, isLoading, updateProfile, isUpdating } = useProfile();
if (isLoading) return <div>Loading profile...</div>;
if (!profile) return <div>Not authenticated</div>;
const handleUpdateBio = async () => {
await updateProfile({ description: "Updated bio!" });
};
return (
<div>
<h2>{profile.displayName}</h2>
<p>{profile.description}</p>
<button onClick={handleUpdateBio} disabled={isUpdating}>
{isUpdating ? "Updating..." : "Update Bio"}
</button>
</div>
);
}// components/HypercertList.tsx
"use client";
import { useHypercerts } from "@/lib/atproto";
export function HypercertList() {
const { hypercerts, isLoading, create, isCreating, error } = useHypercerts();
const handleCreate = async () => {
const result = await create({
title: "Community Garden Impact",
description: "Established a community garden serving 50 families",
workScope: "Urban Agriculture",
workTimeframeFrom: "2024-01-01",
workTimeframeTo: "2024-12-31",
rights: {
name: "CC-BY-4.0",
type: "license",
description: "Creative Commons Attribution 4.0",
},
});
console.log("Created hypercert:", result.hypercertUri);
};
if (isLoading) return <div>Loading hypercerts...</div>;
return (
<div>
<button onClick={handleCreate} disabled={isCreating}>
{isCreating ? "Creating..." : "Create Hypercert"}
</button>
{error && <p className="error">{error.message}</p>}
<ul>
{hypercerts?.map((cert) => (
<li key={cert.uri}>
<h3>{cert.value.title}</h3>
<p>{cert.value.description}</p>
</li>
))}
</ul>
</div>
);
}// components/HypercertDetail.tsx
"use client";
import { useHypercert } from "@/lib/atproto";
export function HypercertDetail({ uri }: { uri: string }) {
const { hypercert, isLoading, update, remove, isUpdating, isDeleting } = useHypercert(uri);
if (isLoading) return <div>Loading...</div>;
if (!hypercert) return <div>Hypercert not found</div>;
return (
<div>
<h1>{hypercert.value.title}</h1>
<p>{hypercert.value.description}</p>
<button onClick={() => update({ title: "Updated Title" })} disabled={isUpdating}>
Update
</button>
<button onClick={() => remove()} disabled={isDeleting}>
Delete
</button>
</div>
);
}// components/Organizations.tsx
"use client";
import { useOrganizations } from "@/lib/atproto";
export function Organizations() {
const { organizations, isLoading, create, isCreating } = useOrganizations();
const handleCreate = async () => {
await create({
name: "Impact Collective",
description: "A collective focused on environmental impact",
});
};
if (isLoading) return <div>Loading...</div>;
return (
<div>
<button onClick={handleCreate} disabled={isCreating}>
Create Organization
</button>
<ul>
{organizations?.map((org) => (
<li key={org.did}>
<h3>{org.name}</h3>
<p>{org.description}</p>
</li>
))}
</ul>
</div>
);
}// components/Projects.tsx
"use client";
import { useProjects } from "@/lib/atproto";
export function Projects() {
const { projects, isLoading, create, isCreating } = useProjects();
const handleCreate = async () => {
await create({
title: "Clean Water Initiative",
shortDescription: "Providing clean water access to rural communities",
description: {
/* Leaflet linear document */
},
activities: [
{ uri: "at://did:plc:xyz/org.hypercerts.claim/abc", cid: "bafyreiabc", weight: "60" },
{ uri: "at://did:plc:xyz/org.hypercerts.claim/def", cid: "bafyreidef", weight: "40" },
],
location: { uri: "at://did:plc:xyz/app.certified.location/loc1", cid: "bafyreiloc" },
});
};
if (isLoading) return <div>Loading...</div>;
return (
<div>
<button onClick={handleCreate} disabled={isCreating}>
Create Project
</button>
<ul>
{projects?.map((project) => (
<li key={project.uri}>
<h3>{project.title}</h3>
<p>{project.shortDescription}</p>
{project.activities && <small>{project.activities.length} activities</small>}
</li>
))}
</ul>
</div>
);
}// components/ProjectDetail.tsx
"use client";
import { useProject } from "@/lib/atproto";
export function ProjectDetail({ uri }: { uri: string }) {
const { project, isLoading, update, remove, isUpdating, isDeleting } = useProject(uri);
if (isLoading) return <div>Loading...</div>;
if (!project) return <div>Project not found</div>;
const handleAddAvatar = async () => {
const avatarBlob = new Blob(
[
/* image data */
],
{ type: "image/png" },
);
await update({ avatar: avatarBlob });
};
return (
<div>
<h1>{project.title}</h1>
<p>{project.shortDescription}</p>
{project.avatar && <img src={/* blob URL */} alt="Project avatar" />}
<button onClick={handleAddAvatar} disabled={isUpdating}>
Add Avatar
</button>
<button onClick={() => update({ title: "Updated Title" })} disabled={isUpdating}>
Update
</button>
<button onClick={() => remove()} disabled={isDeleting}>
Delete
</button>
</div>
);
}// components/Collaborators.tsx
"use client";
import { useCollaborators } from "@/lib/atproto";
export function Collaborators() {
const { collaborators, isLoading, grant, revoke } = useCollaborators();
const handleGrant = async () => {
await grant({
did: "did:plc:example123",
role: "contributor",
permissions: {
canCreate: true,
canUpdate: false,
canDelete: false,
},
});
};
if (isLoading) return <div>Loading...</div>;
return (
<div>
<button onClick={handleGrant}>Add Collaborator</button>
<ul>
{collaborators?.map((collab) => (
<li key={collab.did}>
<span>{collab.did}</span>
<span>{collab.role}</span>
<button onClick={() => revoke(collab.did)}>Remove</button>
</li>
))}
</ul>
</div>
);
}For SSR with hydration, use the SSR helpers:
// lib/atproto.ts (add to existing file)
import { createSSRHelpers } from "@hypercerts-org/sdk-react";
export const ssrHelpers = createSSRHelpers(atproto);// app/hypercerts/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ssrHelpers, queryClient } from "@/lib/atproto";
import { HypercertList } from "@/components/HypercertList";
export default async function HypercertsPage() {
// Prefetch data on the server
await ssrHelpers.prefetchHypercerts(queryClient);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<HypercertList />
</HydrationBoundary>
);
}// pages/hypercerts.tsx
import { dehydrate } from "@tanstack/react-query";
import { ssrHelpers, queryClient } from "@/lib/atproto";
import { HypercertList } from "@/components/HypercertList";
export async function getServerSideProps() {
await ssrHelpers.prefetchHypercerts(queryClient);
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
export default function HypercertsPage() {
return <HypercertList />;
}For the OAuth callback, create an API route:
// app/callback/route.ts (App Router)
import { NextRequest, NextResponse } from "next/server";
import { ATProtoSDK } from "@hypercerts-org/sdk";
const sdk = new ATProtoSDK({
oauth: {
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI!,
scope: "atproto transition:generic",
jwksUri: `${process.env.NEXT_PUBLIC_APP_URL}/.well-known/jwks.json`,
jwkPrivate: process.env.JWK_PRIVATE_KEY!,
},
handleResolver: process.env.NEXT_PUBLIC_HANDLE_RESOLVER_URL || "https://bsky.social",
servers: {
sds: process.env.NEXT_PUBLIC_SDS_URL,
},
});
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
if (error) {
return NextResponse.redirect(new URL(`/?error=${encodeURIComponent(error)}`, request.url));
}
if (!code || !state) {
return NextResponse.redirect(new URL("/?error=missing_params", request.url));
}
try {
const session = await sdk.callback({ code, state });
// Store session in a secure cookie or return to client
const response = NextResponse.redirect(new URL("/", request.url));
response.cookies.set("session", JSON.stringify(session), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return response;
} catch (err) {
console.error("OAuth callback error:", err);
return NextResponse.redirect(new URL("/?error=auth_failed", request.url));
}
}// pages/api/callback.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { ATProtoSDK } from "@hypercerts-org/sdk";
const sdk = new ATProtoSDK({
// ... same config as above
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code, state, error } = req.query;
if (error) {
return res.redirect(`/?error=${encodeURIComponent(String(error))}`);
}
if (!code || !state) {
return res.redirect("/?error=missing_params");
}
try {
const session = await sdk.callback({
code: String(code),
state: String(state),
});
// Set session cookie
res.setHeader(
"Set-Cookie",
`session=${JSON.stringify(session)}; HttpOnly; Secure; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`,
);
return res.redirect("/");
} catch (err) {
console.error("OAuth callback error:", err);
return res.redirect("/?error=auth_failed");
}
}Here's a complete example putting it all together:
// lib/atproto.ts
import { createATProtoReact } from "@hypercerts-org/sdk-react";
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
});
export const atproto = createATProtoReact({
queryClient,
config: {
oauth: {
clientId: process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI!,
scope: "atproto transition:generic",
},
handleResolver: process.env.NEXT_PUBLIC_HANDLE_RESOLVER_URL || "https://bsky.social",
servers: {
sds: process.env.NEXT_PUBLIC_SDS_URL,
},
},
});
export const {
Provider: ATProtoProvider,
useAuth,
useProfile,
useOrganizations,
useProjects,
useProject,
useHypercerts,
useHypercert,
useCollaborators,
} = atproto;// app/providers.tsx
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient, ATProtoProvider } from "@/lib/atproto";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ATProtoProvider>{children}</ATProtoProvider>
</QueryClientProvider>
);
}// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// app/page.tsx
"use client";
import { useAuth, useHypercerts } from "@/lib/atproto";
export default function Home() {
const { status, session, login, logout } = useAuth();
const { hypercerts, isLoading, create } = useHypercerts();
return (
<main>
<h1>Hypercerts App</h1>
{status === "authenticated" ? (
<div>
<p>Welcome, {session?.handle}!</p>
<button onClick={() => logout()}>Sign Out</button>
<h2>Your Hypercerts</h2>
{isLoading ? (
<p>Loading...</p>
) : (
<ul>
{hypercerts?.map((cert) => (
<li key={cert.uri}>{cert.value.title}</li>
))}
</ul>
)}
<button
onClick={() =>
create({
title: "New Impact",
description: "Description of impact",
workScope: "Category",
workTimeframeFrom: "2024-01-01",
workTimeframeTo: "2024-12-31",
rights: {
name: "CC-BY-4.0",
type: "license",
description: "Attribution license",
},
})
}
>
Create Hypercert
</button>
</div>
) : (
<button onClick={() => login("bsky.social")}>Sign in with Bluesky</button>
)}
</main>
);
}import { createATProtoReact } from "@hypercerts-org/sdk-react";
import { WagmiProvider } from "wagmi";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
// Share QueryClient between wagmi and atproto
const queryClient = new QueryClient();
const atproto = createATProtoReact({
queryClient, // Same instance used by wagmi
config: {
/* ... */
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<WagmiProvider config={wagmiConfig}>
<atproto.Provider>
<MyApp />
</atproto.Provider>
</WagmiProvider>
</QueryClientProvider>
);
}import { createATProtoReact } from "@hypercerts-org/sdk-react";
import { trpc } from "./trpc";
// Share QueryClient
const queryClient = new QueryClient();
const atproto = createATProtoReact({
queryClient,
config: {
/* ... */
},
});
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<atproto.Provider>
<MyApp />
</atproto.Provider>
</QueryClientProvider>
</trpc.Provider>
);
}The SDK exports typed errors for better error handling:
import {
AuthenticationError,
SessionExpiredError,
ValidationError,
NetworkError,
SDSRequiredError,
} from "@hypercerts-org/sdk";
try {
await create({
/* ... */
});
} catch (error) {
if (error instanceof SessionExpiredError) {
// Redirect to login
login();
} else if (error instanceof ValidationError) {
// Show validation message
console.error("Invalid data:", error.message);
} else if (error instanceof NetworkError) {
// Retry or show network error
console.error("Network issue:", error.message);
} else if (error instanceof SDSRequiredError) {
// SDS URL not configured
console.error("SDS not configured");
}
}For testing components that use the SDK hooks:
import { render, screen } from "@testing-library/react";
import { TestProvider, createMockSession } from "@hypercerts-org/sdk-react/testing";
import { MyComponent } from "./MyComponent";
describe("MyComponent", () => {
it("renders authenticated state", () => {
const mockSession = createMockSession();
render(
<TestProvider initialSession={mockSession}>
<MyComponent />
</TestProvider>,
);
expect(screen.getByText("Welcome")).toBeInTheDocument();
});
});For manual cache management, use the exported query keys:
import { atprotoKeys, queryClient } from "@/lib/atproto";
// Invalidate all hypercerts
queryClient.invalidateQueries({ queryKey: atprotoKeys.hypercerts.all });
// Invalidate specific hypercert
queryClient.invalidateQueries({
queryKey: atprotoKeys.hypercerts.detail("at://did:plc:.../..."),
});
// Invalidate profile
queryClient.invalidateQueries({ queryKey: atprotoKeys.profile });The SDK supports cross-tab session synchronization:
import { useSessionSync } from "@hypercerts-org/sdk-react";
function App() {
// Automatically sync session changes across browser tabs
useSessionSync();
return <MyApp />;
}