Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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/cluster";
import { CLUSTERS } from "../lib/cluster";
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>
);
}
21 changes: 12 additions & 9 deletions kit/nextjs-anchor/app/components/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"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";

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