Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions kit/nextjs-anchor/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ next-env.d.ts
# anchor build artifacts
/anchor/.anchor/
/anchor/target/

# solana local validator
test-ledger/
99 changes: 73 additions & 26 deletions kit/nextjs-anchor/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# nextjs-anchor

Next.js starter with Tailwind CSS, `@solana/react-hooks`, and an Anchor vault program example.
Next.js starter with Tailwind CSS, `@solana/kit`, and an Anchor vault program example.

## Getting Started

Expand All @@ -9,43 +9,90 @@ npx -y create-solana-dapp@latest -t solana-foundation/templates/kit/nextjs-ancho
```

```shell
npm install # Builds program and generates client automatically
npm install
npm run setup # Builds the Anchor program and generates the TypeScript client
npm run dev
```

Open [http://localhost:3000](http://localhost:3000), connect your wallet, and interact with the vault on devnet.
Open [http://localhost:3000](http://localhost:3000), connect your wallet, and interact with the vault.

## What's Included

- **Wallet connection** via `@solana/react-hooks` with auto-discovery
- **SOL Vault program** - deposit and withdraw SOL from a personal PDA vault
- **Codama-generated client** - type-safe program interactions using `@solana/kit`
- **Tailwind CSS v4** with light/dark mode
- **Wallet connection** via wallet-standard with auto-discovery and dropdown UI
- **Cluster switching** — devnet, testnet, mainnet, and localnet from the header
- **Wallet balance** display with airdrop button (devnet/testnet/localnet)
- **SOL Vault program** — deposit and withdraw SOL from a personal PDA vault
- **Toast notifications** with explorer links for every transaction
- **Error handling** — human-readable messages for common Solana and program errors
- **Codama-generated client** — type-safe program interactions using `@solana/kit`
- **Tailwind CSS v4** with light/dark mode toggle

## Stack

| Layer | Technology |
| -------------- | --------------------------------------- |
| Frontend | Next.js 16, React 19, TypeScript |
| Styling | Tailwind CSS v4 |
| Solana Client | `@solana/client`, `@solana/react-hooks` |
| Program Client | Codama-generated, `@solana/kit` |
| Program | Anchor (Rust) |
| Layer | Technology |
| -------------- | -------------------------------- |
| Frontend | Next.js 16, React 19, TypeScript |
| Styling | Tailwind CSS v4 |
| Solana Client | `@solana/kit`, wallet-standard |
| Program Client | Codama-generated, `@solana/kit` |
| Program | Anchor (Rust) |

## Project Structure

```
├── app/
│ ├── components/
│ │ ├── providers.tsx # Solana client setup
│ │ └── vault-card.tsx # Vault deposit/withdraw UI
│ ├── generated/vault/ # Codama-generated program client
│ └── page.tsx # Main page
├── anchor/ # Anchor workspace
│ └── programs/vault/ # Vault program (Rust)
└── codama.json # Codama client generation config
│ │ ├── cluster-context.tsx # Cluster state (React context + localStorage)
│ │ ├── cluster-select.tsx # Cluster switcher dropdown
│ │ ├── grid-background.tsx # Solana-branded decorative grid
│ │ ├── providers.tsx # Wallet + theme providers
│ │ ├── theme-toggle.tsx # Light/dark mode toggle
│ │ ├── vault-card.tsx # Vault deposit/withdraw UI
│ │ └── wallet-button.tsx # Wallet connect/disconnect dropdown
│ ├── generated/vault/ # Codama-generated program client
│ ├── lib/
│ │ ├── wallet/ # Wallet-standard connection layer
│ │ │ ├── types.ts # Wallet types
│ │ │ ├── standard.ts # Wallet discovery + session creation
│ │ │ ├── signer.ts # WalletSession → TransactionSigner
│ │ │ └── context.tsx # WalletProvider + useWallet() hook
│ │ ├── hooks/
│ │ │ ├── use-balance.ts # SWR-based balance fetching
│ │ │ └── use-send-transaction.ts # Transaction send with loading state
│ │ ├── cluster.ts # Cluster endpoints + RPC factory
│ │ ├── lamports.ts # SOL/lamports conversion
│ │ ├── send-transaction.ts # Transaction build + sign + send pipeline
│ │ ├── errors.ts # Transaction error parsing
│ │ └── explorer.ts # Explorer URL builder + address helpers
│ └── page.tsx # Main page
├── anchor/ # Anchor workspace
│ └── programs/vault/ # Vault program (Rust)
└── codama.json # Codama client generation config
```

## Local Development

To test against a local validator instead of devnet:

1. **Start a local validator**

```bash
solana-test-validator
```

2. **Deploy the program locally**

```bash
solana config set --url localhost
cd anchor
anchor build
anchor deploy
cd ..
npm run codama:js # Regenerate client with local program ID
```

3. **Switch to localnet** in the app using the cluster selector in the header.

## Deploy Your Own Vault

The included vault program is already deployed to devnet. To deploy your own:
Expand Down Expand Up @@ -111,8 +158,8 @@ This uses [Codama](https://github.com/codama-idl/codama) to generate a type-safe

## Learn More

- [Solana Docs](https://solana.com/docs) - core concepts and guides
- [Anchor Docs](https://www.anchor-lang.com/docs) - program development framework
- [Deploying Programs](https://solana.com/docs/programs/deploying) - deployment guide
- [framework-kit](https://github.com/solana-foundation/framework-kit) - the React hooks used here
- [Codama](https://github.com/codama-idl/codama) - client generation from IDL
- [Solana Docs](https://solana.com/docs) core concepts and guides
- [Anchor Docs](https://www.anchor-lang.com/docs/introduction) — program development framework
- [Deploying Programs](https://solana.com/docs/programs/deploying) deployment guide
- [@solana/kit](https://github.com/anza-xyz/kit) — Solana JavaScript SDK
- [Codama](https://github.com/codama-idl/codama) client generation from IDL
61 changes: 61 additions & 0 deletions kit/nextjs-anchor/app/components/cluster-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import type { ClusterMoniker } from "../lib/solana-client";
import { CLUSTERS } from "../lib/solana-client";
import { getExplorerUrl } from "../lib/explorer";

type ClusterContextValue = {
cluster: ClusterMoniker;
setCluster: (cluster: ClusterMoniker) => void;
getExplorerUrl: (path: string) => string;
};

const ClusterContext = createContext<ClusterContextValue | null>(null);

const STORAGE_KEY = "solana-cluster";
function getInitialCluster(): ClusterMoniker {
if (typeof window === "undefined") return "devnet";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && CLUSTERS.includes(stored as ClusterMoniker)) {
return stored as ClusterMoniker;
}
return "devnet";
}

export { CLUSTERS };

export function ClusterProvider({ children }: { children: ReactNode }) {
const [cluster, setClusterState] =
useState<ClusterMoniker>(getInitialCluster);

const setCluster = useCallback((c: ClusterMoniker) => {
setClusterState(c);
localStorage.setItem(STORAGE_KEY, c);
}, []);

const explorerUrl = useCallback(
(path: string) => getExplorerUrl(path, cluster),
[cluster]
);

return (
<ClusterContext.Provider
value={{ cluster, setCluster, getExplorerUrl: explorerUrl }}
>
{children}
</ClusterContext.Provider>
);
}

export function useCluster() {
const ctx = useContext(ClusterContext);
if (!ctx) throw new Error("useCluster must be used within ClusterProvider");
return ctx;
}
78 changes: 78 additions & 0 deletions kit/nextjs-anchor/app/components/cluster-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import { useState, useRef, useEffect } from "react";
import { useCluster, CLUSTERS } from "./cluster-context";

export function ClusterSelect() {
const { cluster, setCluster } = useCluster();
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);

return (
<div className="relative" ref={ref}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border-low bg-card px-3 py-2 text-xs font-medium transition hover:bg-cream"
>
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor:
cluster === "mainnet"
? "#22c55e"
: cluster === "devnet"
? "#3b82f6"
: cluster === "testnet"
? "#eab308"
: "#a3a3a3",
}}
/>
{cluster}
</button>

{isOpen && (
<div className="absolute right-0 top-full z-50 mt-2 w-40 rounded-xl border border-border-low bg-card p-2 shadow-lg">
<div className="space-y-1">
{CLUSTERS.map((c) => (
<button
key={c}
onClick={() => {
setCluster(c);
setIsOpen(false);
}}
className={`flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-left text-xs font-medium transition hover:bg-cream ${
c === cluster ? "bg-cream" : ""
}`}
>
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor:
c === "mainnet"
? "#22c55e"
: c === "devnet"
? "#3b82f6"
: c === "testnet"
? "#eab308"
: "#a3a3a3",
}}
/>
{c}
</button>
))}
</div>
</div>
)}
</div>
);
}
78 changes: 78 additions & 0 deletions kit/nextjs-anchor/app/components/grid-background.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

export function GridBackground() {
return (
<div className="pointer-events-none fixed inset-0 z-0" aria-hidden="true">
{/* Ambient glow */}
<div
className="absolute inset-0 transition-opacity duration-500"
style={{
background: [
"radial-gradient(ellipse 30% 28% at 30% 50%, rgba(153,69,255,0.08) 0%, transparent 70%)",
"radial-gradient(ellipse 30% 28% at 70% 50%, rgba(20,241,149,0.08) 0%, transparent 70%)",
].join(", "),
}}
/>

{/* Large grid — purple (left) */}
<div
className="absolute inset-0 opacity-80 dark:opacity-60"
style={{
backgroundImage: `
linear-gradient(to right, rgba(153,69,255,0.18) 1px, transparent 1px),
linear-gradient(to bottom, rgba(153,69,255,0.18) 1px, transparent 1px)
`,
backgroundSize: "80px 80px",
mask: "radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
WebkitMask:
"radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
}}
/>

{/* Large grid — green (right) */}
<div
className="absolute inset-0 opacity-80 dark:opacity-60"
style={{
backgroundImage: `
linear-gradient(to right, rgba(20,241,149,0.18) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20,241,149,0.18) 1px, transparent 1px)
`,
backgroundSize: "80px 80px",
mask: "radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
WebkitMask:
"radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
}}
/>

{/* Small grid — purple (left) */}
<div
className="absolute inset-0 opacity-80 dark:opacity-60"
style={{
backgroundImage: `
linear-gradient(to right, rgba(153,69,255,0.10) 1px, transparent 1px),
linear-gradient(to bottom, rgba(153,69,255,0.10) 1px, transparent 1px)
`,
backgroundSize: "16px 16px",
mask: "radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
WebkitMask:
"radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
}}
/>

{/* Small grid — green (right) */}
<div
className="absolute inset-0 opacity-80 dark:opacity-60"
style={{
backgroundImage: `
linear-gradient(to right, rgba(20,241,149,0.10) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20,241,149,0.10) 1px, transparent 1px)
`,
backgroundSize: "16px 16px",
mask: "radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
WebkitMask:
"radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
}}
/>
</div>
);
}
24 changes: 15 additions & 9 deletions kit/nextjs-anchor/app/components/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
"use client";

import { SolanaProvider } from "@solana/react-hooks";
import { ThemeProvider } from "next-themes";
import { Toaster } from "sonner";
import { PropsWithChildren } from "react";

import { autoDiscover, createClient } from "@solana/client";

const client = createClient({
endpoint: "https://api.devnet.solana.com",
walletConnectors: autoDiscover(),
});
import { ClusterProvider } from "./cluster-context";
import { WalletProvider } from "../lib/wallet/context";
import { SolanaClientProvider } from "../lib/solana-client-context";

export function Providers({ children }: PropsWithChildren) {
return <SolanaProvider client={client}>{children}</SolanaProvider>;
return (
<ThemeProvider attribute="class" defaultTheme="dark">
<ClusterProvider>
<SolanaClientProvider>
<WalletProvider>{children}</WalletProvider>
</SolanaClientProvider>
<Toaster position="bottom-right" richColors />
</ClusterProvider>
</ThemeProvider>
);
}
Loading
Loading