Skip to content

Commit 7ed329c

Browse files
committed
Migrate kit/nextjs template to @solana/kit and @solana/kit-client-rpc
1 parent c35440f commit 7ed329c

22 files changed

+9246
-188
lines changed

kit/nextjs/README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# nextjs
22

3-
Next.js starter with Tailwind CSS and `@solana/kit` for wallet connection and Solana hooks.
3+
Next.js starter with Tailwind CSS, [`@solana/kit`](https://github.com/anza-xyz/kit) and [`@solana/kit-client-rpc`](https://github.com/anza-xyz/kit-plugins) for wallet connection, balance display, and cluster switching.
44

55
## Getting Started
66

@@ -12,3 +12,25 @@ npx -y create-solana-dapp@latest -t solana-foundation/templates/kit/nextjs
1212
npm install
1313
npm run dev
1414
```
15+
16+
## What's Included
17+
18+
- Wallet connection via [wallet-standard](https://github.com/wallet-standard/wallet-standard) (auto-discovers Phantom, Solflare, etc.)
19+
- Balance display with real-time WebSocket updates
20+
- Devnet airdrop
21+
- Cluster switching (devnet, testnet, mainnet, localnet)
22+
- Light/dark theme toggle
23+
24+
## Using Your Own RPC
25+
26+
The public mainnet RPC (`api.mainnet-beta.solana.com`) rejects requests from browser origins. To use mainnet, replace the URL in `app/lib/solana-client.ts` with your own RPC provider (e.g. [Helius](https://www.helius.dev/), [Triton](https://triton.one/)).
27+
28+
## Stack
29+
30+
- [Next.js](https://nextjs.org/) 16 (App Router)
31+
- [Tailwind CSS](https://tailwindcss.com/) v4
32+
- [@solana/kit](https://github.com/anza-xyz/kit) for RPC, signers, and transaction types
33+
- [@solana/kit-client-rpc](https://github.com/anza-xyz/kit-plugins) for the plugin-based client
34+
- [SWR](https://swr.vercel.app/) for data fetching
35+
- [sonner](https://sonner.emilkowal.ski/) for toast notifications
36+
- [next-themes](https://github.com/pacocoursey/next-themes) for dark mode
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use client";
2+
3+
import {
4+
createContext,
5+
useContext,
6+
useState,
7+
useCallback,
8+
useSyncExternalStore,
9+
type ReactNode,
10+
} from "react";
11+
import type { ClusterMoniker } from "../lib/solana-client";
12+
import { CLUSTERS } from "../lib/solana-client";
13+
import { getExplorerUrl } from "../lib/explorer";
14+
15+
type ClusterContextValue = {
16+
cluster: ClusterMoniker;
17+
setCluster: (cluster: ClusterMoniker) => void;
18+
getExplorerUrl: (path: string) => string;
19+
};
20+
21+
const ClusterContext = createContext<ClusterContextValue | null>(null);
22+
23+
const STORAGE_KEY = "solana-cluster";
24+
25+
function readStoredCluster(): ClusterMoniker {
26+
const stored = localStorage.getItem(STORAGE_KEY);
27+
if (stored && CLUSTERS.includes(stored as ClusterMoniker)) {
28+
return stored as ClusterMoniker;
29+
}
30+
return "devnet";
31+
}
32+
33+
export { CLUSTERS };
34+
35+
export function ClusterProvider({ children }: { children: ReactNode }) {
36+
// Subscribe to nothing (no external changes), but use useSyncExternalStore
37+
// so localStorage is read on the client without hydration mismatch
38+
const initialCluster = useSyncExternalStore(
39+
() => () => {},
40+
readStoredCluster,
41+
() => "devnet" as ClusterMoniker
42+
);
43+
44+
const [cluster, setClusterState] = useState<ClusterMoniker>(initialCluster);
45+
46+
const setCluster = useCallback((c: ClusterMoniker) => {
47+
setClusterState(c);
48+
localStorage.setItem(STORAGE_KEY, c);
49+
}, []);
50+
51+
const explorerUrl = useCallback(
52+
(path: string) => getExplorerUrl(path, cluster),
53+
[cluster]
54+
);
55+
56+
return (
57+
<ClusterContext.Provider
58+
value={{ cluster, setCluster, getExplorerUrl: explorerUrl }}
59+
>
60+
{children}
61+
</ClusterContext.Provider>
62+
);
63+
}
64+
65+
export function useCluster() {
66+
const ctx = useContext(ClusterContext);
67+
if (!ctx) throw new Error("useCluster must be used within ClusterProvider");
68+
return ctx;
69+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"use client";
2+
3+
import { useState, useRef, useEffect } from "react";
4+
import { toast } from "sonner";
5+
import { useCluster, CLUSTERS } from "./cluster-context";
6+
7+
export function ClusterSelect() {
8+
const { cluster, setCluster } = useCluster();
9+
const [isOpen, setIsOpen] = useState(false);
10+
const ref = useRef<HTMLDivElement>(null);
11+
12+
useEffect(() => {
13+
function handleClickOutside(e: MouseEvent) {
14+
if (ref.current && !ref.current.contains(e.target as Node)) {
15+
setIsOpen(false);
16+
}
17+
}
18+
document.addEventListener("mousedown", handleClickOutside);
19+
return () => document.removeEventListener("mousedown", handleClickOutside);
20+
}, []);
21+
22+
return (
23+
<div className="relative" ref={ref}>
24+
<button
25+
onClick={() => setIsOpen(!isOpen)}
26+
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"
27+
>
28+
<span
29+
className="h-2 w-2 rounded-full"
30+
style={{
31+
backgroundColor:
32+
cluster === "mainnet"
33+
? "#22c55e"
34+
: cluster === "devnet"
35+
? "#3b82f6"
36+
: cluster === "testnet"
37+
? "#eab308"
38+
: "#a3a3a3",
39+
}}
40+
/>
41+
{cluster}
42+
</button>
43+
44+
{isOpen && (
45+
<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">
46+
<div className="space-y-1">
47+
{CLUSTERS.map((c) => (
48+
<button
49+
key={c}
50+
onClick={() => {
51+
setCluster(c);
52+
setIsOpen(false);
53+
if (c === "mainnet") {
54+
toast.info("You'll need your own RPC for mainnet.", {
55+
duration: 8000,
56+
description: (
57+
<span>
58+
The public mainnet RPC rejects requests from browser
59+
origins. Swap the URL in{" "}
60+
<code className="rounded bg-black/10 px-1 py-0.5 font-mono text-xs dark:bg-white/10">
61+
solana-client.ts
62+
</code>{" "}
63+
with a provider like{" "}
64+
<a
65+
href="https://www.helius.dev/"
66+
target="_blank"
67+
rel="noopener noreferrer"
68+
className="underline"
69+
>
70+
Helius
71+
</a>
72+
{" or "}
73+
<a
74+
href="https://triton.one/"
75+
target="_blank"
76+
rel="noopener noreferrer"
77+
className="underline"
78+
>
79+
Triton
80+
</a>
81+
.
82+
</span>
83+
),
84+
});
85+
}
86+
}}
87+
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 ${
88+
c === cluster ? "bg-cream" : ""
89+
}`}
90+
>
91+
<span
92+
className="h-2 w-2 rounded-full"
93+
style={{
94+
backgroundColor:
95+
c === "mainnet"
96+
? "#22c55e"
97+
: c === "devnet"
98+
? "#3b82f6"
99+
: c === "testnet"
100+
? "#eab308"
101+
: "#a3a3a3",
102+
}}
103+
/>
104+
{c}
105+
</button>
106+
))}
107+
</div>
108+
</div>
109+
)}
110+
</div>
111+
);
112+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"use client";
2+
3+
export function GridBackground() {
4+
return (
5+
<div className="pointer-events-none fixed inset-0 z-0" aria-hidden="true">
6+
{/* Ambient glow */}
7+
<div
8+
className="absolute inset-0 transition-opacity duration-500"
9+
style={{
10+
background: [
11+
"radial-gradient(ellipse 30% 28% at 30% 50%, rgba(153,69,255,0.08) 0%, transparent 70%)",
12+
"radial-gradient(ellipse 30% 28% at 70% 50%, rgba(20,241,149,0.08) 0%, transparent 70%)",
13+
].join(", "),
14+
}}
15+
/>
16+
17+
{/* Large grid — purple (left) */}
18+
<div
19+
className="absolute inset-0 opacity-80 dark:opacity-60"
20+
style={{
21+
backgroundImage: `
22+
linear-gradient(to right, rgba(153,69,255,0.18) 1px, transparent 1px),
23+
linear-gradient(to bottom, rgba(153,69,255,0.18) 1px, transparent 1px)
24+
`,
25+
backgroundSize: "80px 80px",
26+
mask: "radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
27+
WebkitMask:
28+
"radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
29+
}}
30+
/>
31+
32+
{/* Large grid — green (right) */}
33+
<div
34+
className="absolute inset-0 opacity-80 dark:opacity-60"
35+
style={{
36+
backgroundImage: `
37+
linear-gradient(to right, rgba(20,241,149,0.18) 1px, transparent 1px),
38+
linear-gradient(to bottom, rgba(20,241,149,0.18) 1px, transparent 1px)
39+
`,
40+
backgroundSize: "80px 80px",
41+
mask: "radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
42+
WebkitMask:
43+
"radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
44+
}}
45+
/>
46+
47+
{/* Small grid — purple (left) */}
48+
<div
49+
className="absolute inset-0 opacity-80 dark:opacity-60"
50+
style={{
51+
backgroundImage: `
52+
linear-gradient(to right, rgba(153,69,255,0.10) 1px, transparent 1px),
53+
linear-gradient(to bottom, rgba(153,69,255,0.10) 1px, transparent 1px)
54+
`,
55+
backgroundSize: "16px 16px",
56+
mask: "radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
57+
WebkitMask:
58+
"radial-gradient(ellipse 30% 35% at 30% 50%, black, transparent)",
59+
}}
60+
/>
61+
62+
{/* Small grid — green (right) */}
63+
<div
64+
className="absolute inset-0 opacity-80 dark:opacity-60"
65+
style={{
66+
backgroundImage: `
67+
linear-gradient(to right, rgba(20,241,149,0.10) 1px, transparent 1px),
68+
linear-gradient(to bottom, rgba(20,241,149,0.10) 1px, transparent 1px)
69+
`,
70+
backgroundSize: "16px 16px",
71+
mask: "radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
72+
WebkitMask:
73+
"radial-gradient(ellipse 30% 35% at 70% 50%, black, transparent)",
74+
}}
75+
/>
76+
</div>
77+
);
78+
}
Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"use client";
22

3-
import { SolanaProvider } from "@solana/react-hooks";
3+
import { ThemeProvider } from "next-themes";
4+
import { Toaster } from "sonner";
45
import { PropsWithChildren } from "react";
5-
6-
import { autoDiscover, createClient } from "@solana/client";
7-
8-
const client = createClient({
9-
endpoint: "https://api.devnet.solana.com",
10-
walletConnectors: autoDiscover(),
11-
});
6+
import { ClusterProvider } from "./cluster-context";
7+
import { WalletProvider } from "../lib/wallet/context";
8+
import { SolanaClientProvider } from "../lib/solana-client-context";
129

1310
export function Providers({ children }: PropsWithChildren) {
14-
return <SolanaProvider client={client}>{children}</SolanaProvider>;
11+
return (
12+
<ThemeProvider attribute="class" defaultTheme="dark">
13+
<ClusterProvider>
14+
<WalletProvider>
15+
<SolanaClientProvider>{children}</SolanaClientProvider>
16+
</WalletProvider>
17+
<Toaster position="bottom-right" richColors />
18+
</ClusterProvider>
19+
</ThemeProvider>
20+
);
1521
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"use client";
2+
3+
import { useSyncExternalStore } from "react";
4+
import { useTheme } from "next-themes";
5+
6+
const subscribe = () => () => {};
7+
8+
export function ThemeToggle() {
9+
const { resolvedTheme, setTheme } = useTheme();
10+
const mounted = useSyncExternalStore(
11+
subscribe,
12+
() => true,
13+
() => false
14+
);
15+
16+
const isDark = resolvedTheme === "dark";
17+
18+
return (
19+
<button
20+
onClick={() => setTheme(isDark ? "light" : "dark")}
21+
className="inline-flex size-9 cursor-pointer items-center justify-center rounded-lg border border-border-low bg-card text-sm transition hover:bg-cream"
22+
aria-label="Toggle theme"
23+
>
24+
{mounted ? (
25+
isDark ? (
26+
<svg
27+
xmlns="http://www.w3.org/2000/svg"
28+
width="16"
29+
height="16"
30+
viewBox="0 0 24 24"
31+
fill="none"
32+
stroke="currentColor"
33+
strokeWidth="2"
34+
strokeLinecap="round"
35+
strokeLinejoin="round"
36+
>
37+
<circle cx="12" cy="12" r="4" />
38+
<path d="M12 2v2" />
39+
<path d="M12 20v2" />
40+
<path d="m4.93 4.93 1.41 1.41" />
41+
<path d="m17.66 17.66 1.41 1.41" />
42+
<path d="M2 12h2" />
43+
<path d="M20 12h2" />
44+
<path d="m6.34 17.66-1.41 1.41" />
45+
<path d="m19.07 4.93-1.41 1.41" />
46+
</svg>
47+
) : (
48+
<svg
49+
xmlns="http://www.w3.org/2000/svg"
50+
width="16"
51+
height="16"
52+
viewBox="0 0 24 24"
53+
fill="none"
54+
stroke="currentColor"
55+
strokeWidth="2"
56+
strokeLinecap="round"
57+
strokeLinejoin="round"
58+
>
59+
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
60+
</svg>
61+
)
62+
) : (
63+
<span className="size-4" />
64+
)}
65+
</button>
66+
);
67+
}

0 commit comments

Comments
 (0)