솔라나 문서프론트엔드

Next.js - Solana Kit

Solana Kit을 사용하여 Next.js에서 최소한의 Solana 지갑 통합을 설정하세요.

이 가이드는 @solana/kit을 사용하여 Next.js 애플리케이션에서 Solana 지갑 기능을 구현하는 최소한의 예시를 제공합니다. 지갑 연결 버튼과 트랜잭션을 보내는 컴포넌트를 만들게 됩니다.

Nextjs Kit 애플리케이션Nextjs Kit 애플리케이션

React 애플리케이션에서 @solana/kit을 사용하는 더 포괄적인 예시는 Solana Kit 저장소의 React 앱 예시를 참조하세요.

리소스

사전 요구 사항

Next.js 프로젝트 생성

UI 컴포넌트를 위한 shadcn이 포함된 새로운 Next.js 프로젝트를 생성하고 필요한 Solana 의존성을 설치하세요.

Terminal
$
npx shadcn@latest init

프로젝트 디렉토리로 이동하세요:

Terminal
$
cd <project-name>

UI 컴포넌트 설치

다음 shadcn UI 컴포넌트를 설치하세요:

Terminal
$
npx shadcn@latest add button dropdown-menu avatar

Solana 의존성 설치

다음 Solana 의존성을 설치하세요:

Terminal
$
npm install @solana/kit @solana/react @wallet-standard/core @wallet-standard/react @solana-program/memo

구현 안내

아래 단계를 따르고 제공된 코드를 프로젝트에 복사하세요.

1. Solana 컨텍스트 생성

먼저, 애플리케이션 전체의 지갑 상태를 관리하는 React 컨텍스트를 생성합니다.

components/solana-provider.tsx를 생성하고 제공된 코드를 추가하세요. 이 프로바이더 컴포넌트는 다음과 같은 기능을 수행합니다:

  • Solana의 devnet RPC 엔드포인트에 연결
  • 사용자 브라우저에 설치된 사용 가능한 Solana 지갑 필터링
  • 현재 연결된 지갑과 계정 추적
  • 자식 컴포넌트에 지갑 상태 제공
components/solana-provider.tsx
"use client";
import React, { createContext, useContext, useState, useMemo } from "react";
import {
useWallets,
type UiWallet,
type UiWalletAccount
} from "@wallet-standard/react";
import { createSolanaRpc, createSolanaRpcSubscriptions } from "@solana/kit";
import { StandardConnect } from "@wallet-standard/core";
// Create RPC connection
const RPC_ENDPOINT = "https://api.devnet.solana.com";
const WS_ENDPOINT = "wss://api.devnet.solana.com";
const chain = "solana:devnet";
const rpc = createSolanaRpc(RPC_ENDPOINT);
const ws = createSolanaRpcSubscriptions(WS_ENDPOINT);
interface SolanaContextState {
// RPC
rpc: ReturnType<typeof createSolanaRpc>;
ws: ReturnType<typeof createSolanaRpcSubscriptions>;
chain: typeof chain;
// Wallet State
wallets: UiWallet[];
selectedWallet: UiWallet | null;
selectedAccount: UiWalletAccount | null;
isConnected: boolean;
// Wallet Actions
setWalletAndAccount: (
wallet: UiWallet | null,
account: UiWalletAccount | null
) => void;
}
const SolanaContext = createContext<SolanaContextState | undefined>(undefined);
export function useSolana() {
const context = useContext(SolanaContext);
if (!context) {
throw new Error("useSolana must be used within a SolanaProvider");
}
return context;
}
export function SolanaProvider({ children }: { children: React.ReactNode }) {
const allWallets = useWallets();
// Filter for Solana wallets only that support signAndSendTransaction
const wallets = useMemo(() => {
return allWallets.filter(
(wallet) =>
wallet.chains?.some((c) => c.startsWith("solana:")) &&
wallet.features.includes(StandardConnect) &&
wallet.features.includes("solana:signAndSendTransaction")
);
}, [allWallets]);
// State management
const [selectedWallet, setSelectedWallet] = useState<UiWallet | null>(null);
const [selectedAccount, setSelectedAccount] =
useState<UiWalletAccount | null>(null);
// Check if connected (account must exist in the wallet's accounts)
const isConnected = useMemo(() => {
if (!selectedAccount || !selectedWallet) return false;
// Find the wallet and check if it still has this account
const currentWallet = wallets.find((w) => w.name === selectedWallet.name);
return !!(
currentWallet &&
currentWallet.accounts.some(
(acc) => acc.address === selectedAccount.address
)
);
}, [selectedAccount, selectedWallet, wallets]);
const setWalletAndAccount = (
wallet: UiWallet | null,
account: UiWalletAccount | null
) => {
setSelectedWallet(wallet);
setSelectedAccount(account);
};
// Create context value
const contextValue = useMemo<SolanaContextState>(
() => ({
// Static RPC values
rpc,
ws,
chain,
// Dynamic wallet values
wallets,
selectedWallet,
selectedAccount,
isConnected,
setWalletAndAccount
}),
[wallets, selectedWallet, selectedAccount, isConnected]
);
return (
<SolanaContext.Provider value={contextValue}>
{children}
</SolanaContext.Provider>
);
}

2. 레이아웃 업데이트

다음으로, 전체 Next.js 애플리케이션을 Solana 프로바이더로 감싸줍니다.

app/layout.tsx를 제공된 코드로 업데이트하세요. 이 단계에서는:

  • SolanaProvider 컴포넌트를 가져옵니다
  • 애플리케이션의 자식 컴포넌트를 SolanaProvider로 감쌉니다
  • 모든 페이지와 컴포넌트가 지갑 기능에 접근할 수 있도록 합니다
app/layout.tsx
import { SolanaProvider } from "@/components/solana-provider";
import "./globals.css";
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<SolanaProvider>{children}</SolanaProvider>
</body>
</html>
);
}

3. 지갑 연결 버튼 생성

이제 지갑을 연결하고 연결 해제하는 버튼을 만들어 보겠습니다.

components/wallet-connect-button.tsx를 생성하고 제공된 코드를 추가하세요. 이 드롭다운 버튼은:

  • 클릭 시 사용 가능한 지갑을 표시합니다
  • 지갑 표준을 사용하여 지갑 연결 흐름을 처리합니다
components/wallet-connect-button.tsx
"use client";
import { useState } from "react";
import { useSolana } from "@/components/solana-provider";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ChevronDown, Wallet, LogOut } from "lucide-react";
import {
useConnect,
useDisconnect,
type UiWallet
} from "@wallet-standard/react";
function truncateAddress(address: string): string {
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}
function WalletIcon({
wallet,
className
}: {
wallet: UiWallet;
className?: string;
}) {
return (
<Avatar className={className}>
{wallet.icon && (
<AvatarImage src={wallet.icon} alt={`${wallet.name} icon`} />
)}
<AvatarFallback>{wallet.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
function WalletMenuItem({
wallet,
onConnect
}: {
wallet: UiWallet;
onConnect: () => void;
}) {
const { setWalletAndAccount } = useSolana();
const [isConnecting, connect] = useConnect(wallet);
const handleConnect = async () => {
if (isConnecting) return;
try {
const accounts = await connect();
if (accounts && accounts.length > 0) {
const account = accounts[0];
setWalletAndAccount(wallet, account);
onConnect();
}
} catch (err) {
console.error(`Failed to connect ${wallet.name}:`, err);
}
};
return (
<button
className="flex w-full items-center justify-between px-2 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent disabled:pointer-events-none disabled:opacity-50"
onClick={handleConnect}
disabled={isConnecting}
>
<div className="flex items-center gap-2">
<WalletIcon wallet={wallet} className="h-6 w-6" />
<span className="font-medium">{wallet.name}</span>
</div>
</button>
);
}
function DisconnectButton({
wallet,
onDisconnect
}: {
wallet: UiWallet;
onDisconnect: () => void;
}) {
const { setWalletAndAccount } = useSolana();
const [isDisconnecting, disconnect] = useDisconnect(wallet);
const handleDisconnect = async () => {
try {
await disconnect();
setWalletAndAccount(null, null);
onDisconnect();
} catch (err) {
console.error("Failed to disconnect wallet:", err);
}
};
return (
<DropdownMenuItem
className="text-destructive focus:text-destructive cursor-pointer"
onClick={handleDisconnect}
disabled={isDisconnecting}
>
<LogOut className="mr-2 h-4 w-4" />
Disconnect
</DropdownMenuItem>
);
}
export function WalletConnectButton() {
const { wallets, selectedWallet, selectedAccount, isConnected } = useSolana();
const [dropdownOpen, setDropdownOpen] = useState(false);
return (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="min-w-[140px] justify-between">
{isConnected && selectedWallet && selectedAccount ? (
<>
<div className="flex items-center gap-2">
<WalletIcon wallet={selectedWallet} className="h-4 w-4" />
<span className="font-mono text-sm">
{truncateAddress(selectedAccount.address)}
</span>
</div>
<ChevronDown className="ml-2 h-4 w-4" />
</>
) : (
<>
<Wallet className="mr-2 h-4 w-4" />
<span>Connect Wallet</span>
<ChevronDown className="ml-2 h-4 w-4" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
{wallets.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
No wallets detected
</p>
) : (
<>
{!isConnected ? (
<>
<DropdownMenuLabel>Available Wallets</DropdownMenuLabel>
<DropdownMenuSeparator />
{wallets.map((wallet, index) => (
<WalletMenuItem
key={`${wallet.name}-${index}`}
wallet={wallet}
onConnect={() => setDropdownOpen(false)}
/>
))}
</>
) : (
selectedWallet &&
selectedAccount && (
<>
<DropdownMenuLabel>Connected Wallet</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<div className="flex items-center gap-2">
<WalletIcon wallet={selectedWallet} className="h-6 w-6" />
<div className="flex flex-col">
<span className="text-sm font-medium">
{selectedWallet.name}
</span>
<span className="text-xs text-muted-foreground font-mono">
{truncateAddress(selectedAccount.address)}
</span>
</div>
</div>
</div>
<DropdownMenuSeparator />
<DisconnectButton
wallet={selectedWallet}
onDisconnect={() => setDropdownOpen(false)}
/>
</>
)
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

4. 트랜잭션 전송 컴포넌트 생성

메모 프로그램을 호출하여 트랜잭션 로그에 메시지를 추가하는 트랜잭션을 보내는 컴포넌트를 만듭니다.

이 컴포넌트의 목적은 연결된 지갑으로 트랜잭션을 보내는 방법을 보여주는 것입니다.

components/memo-card.tsx를 만들고 제공된 코드를 추가하세요. 이 컴포넌트는:

  • 사용자가 메시지를 입력할 수 있게 합니다
  • 메모 프로그램을 호출하는 명령어가 포함된 Solana 트랜잭션을 생성합니다
  • 연결된 지갑에 트랜잭션 서명 및 전송을 요청합니다
  • Solana 익스플로러에서 트랜잭션을 볼 수 있는 링크를 표시합니다
components/memo-card.tsx
"use client";
import { useState } from "react";
import { useSolana } from "@/components/solana-provider";
import { useWalletAccountTransactionSendingSigner } from "@solana/react";
import { type UiWalletAccount } from "@wallet-standard/react";
import {
pipe,
createTransactionMessage,
appendTransactionMessageInstruction,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signAndSendTransactionMessageWithSigners,
getBase58Decoder,
type Signature
} from "@solana/kit";
import { getAddMemoInstruction } from "@solana-program/memo";
// Component that only renders when wallet is connected
function ConnectedMemoCard({ account }: { account: UiWalletAccount }) {
const { rpc, chain } = useSolana();
const [isLoading, setIsLoading] = useState(false);
const [memoText, setMemoText] = useState("");
const [txSignature, setTxSignature] = useState("");
const signer = useWalletAccountTransactionSendingSigner(account, chain);
const sendMemo = async () => {
if (!signer) return;
setIsLoading(true);
try {
const { value: latestBlockhash } = await rpc
.getLatestBlockhash({ commitment: "confirmed" })
.send();
const memoInstruction = getAddMemoInstruction({ memo: memoText });
const message = pipe(
createTransactionMessage({ version: 0 }),
(m) => setTransactionMessageFeePayerSigner(signer, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) => appendTransactionMessageInstruction(memoInstruction, m)
);
const signature = await signAndSendTransactionMessageWithSigners(message);
const signatureStr = getBase58Decoder().decode(signature) as Signature;
setTxSignature(signatureStr);
setMemoText("");
} catch (error) {
console.error("Memo failed:", error);
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm mb-1">Memo Message</label>
<textarea
value={memoText}
onChange={(e) => setMemoText(e.target.value)}
placeholder="Enter your memo message"
className="w-full p-2 border rounded min-h-[100px]"
maxLength={566}
/>
</div>
<button
onClick={sendMemo}
disabled={isLoading || !memoText.trim()}
className="w-full p-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400"
>
{isLoading ? "Sending..." : "Send Memo"}
</button>
{txSignature && (
<div className="p-2 border rounded text-sm">
<p className="mb-1">Memo Sent</p>
<a
href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
View on Solana Explorer →
</a>
</div>
)}
</div>
);
}
// Main memo component
export function MemoCard() {
const { selectedAccount, isConnected } = useSolana();
return (
<div className="space-y-4 p-4 border rounded-lg">
<h3 className="text-lg font-semibold">Send Memo</h3>
{isConnected && selectedAccount ? (
<ConnectedMemoCard account={selectedAccount} />
) : (
<p className="text-gray-500 text-center py-4">
Connect your wallet to send a memo
</p>
)}
</div>
);
}

5. 앱 페이지 업데이트

마지막으로, 메인 앱 페이지를 업데이트합니다.

app/page.tsx를 제공된 코드로 업데이트하세요. 이 페이지는:

  • WalletConnectButtonMemoCard 컴포넌트를 가져와 사용합니다
app/page.tsx
"use client";
import { WalletConnectButton } from "@/components/wallet-connect-button";
import { MemoCard } from "@/components/memo-card";
export default function Home() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md bg-card rounded-lg border shadow-lg p-6 space-y-6">
<div className="flex justify-center">
<WalletConnectButton />
</div>
<MemoCard />
</div>
</div>
);
}

6. 애플리케이션 실행

이제 애플리케이션을 실행하여 지갑 통합을 테스트합니다.

Terminal
$
npm run dev

연결된 지갑은 devnet 클러스터에 연결되도록 구성되어 있어야 하며 트랜잭션을 보내기 위해 devnet SOL로 충전되어 있어야 합니다.

1. Solana 컨텍스트 생성

먼저, 애플리케이션 전체의 지갑 상태를 관리하는 React 컨텍스트를 생성합니다.

components/solana-provider.tsx를 생성하고 제공된 코드를 추가하세요. 이 프로바이더 컴포넌트는 다음과 같은 기능을 수행합니다:

  • Solana의 devnet RPC 엔드포인트에 연결
  • 사용자 브라우저에 설치된 사용 가능한 Solana 지갑 필터링
  • 현재 연결된 지갑과 계정 추적
  • 자식 컴포넌트에 지갑 상태 제공

2. 레이아웃 업데이트

다음으로, 전체 Next.js 애플리케이션을 Solana 프로바이더로 감싸줍니다.

app/layout.tsx를 제공된 코드로 업데이트하세요. 이 단계에서는:

  • SolanaProvider 컴포넌트를 가져옵니다
  • 애플리케이션의 자식 컴포넌트를 SolanaProvider로 감쌉니다
  • 모든 페이지와 컴포넌트가 지갑 기능에 접근할 수 있도록 합니다

3. 지갑 연결 버튼 생성

이제 지갑을 연결하고 연결 해제하는 버튼을 만들어 보겠습니다.

components/wallet-connect-button.tsx를 생성하고 제공된 코드를 추가하세요. 이 드롭다운 버튼은:

  • 클릭 시 사용 가능한 지갑을 표시합니다
  • 지갑 표준을 사용하여 지갑 연결 흐름을 처리합니다

4. 트랜잭션 전송 컴포넌트 생성

메모 프로그램을 호출하여 트랜잭션 로그에 메시지를 추가하는 트랜잭션을 보내는 컴포넌트를 만듭니다.

이 컴포넌트의 목적은 연결된 지갑으로 트랜잭션을 보내는 방법을 보여주는 것입니다.

components/memo-card.tsx를 만들고 제공된 코드를 추가하세요. 이 컴포넌트는:

  • 사용자가 메시지를 입력할 수 있게 합니다
  • 메모 프로그램을 호출하는 명령어가 포함된 Solana 트랜잭션을 생성합니다
  • 연결된 지갑에 트랜잭션 서명 및 전송을 요청합니다
  • Solana 익스플로러에서 트랜잭션을 볼 수 있는 링크를 표시합니다

5. 앱 페이지 업데이트

마지막으로, 메인 앱 페이지를 업데이트합니다.

app/page.tsx를 제공된 코드로 업데이트하세요. 이 페이지는:

  • WalletConnectButtonMemoCard 컴포넌트를 가져와 사용합니다

6. 애플리케이션 실행

이제 애플리케이션을 실행하여 지갑 통합을 테스트합니다.

Terminal
$
npm run dev

연결된 지갑은 devnet 클러스터에 연결되도록 구성되어 있어야 하며 트랜잭션을 보내기 위해 devnet SOL로 충전되어 있어야 합니다.

layout.tsx
page.tsx
solana-provider.tsx
package.json
"use client";
import React, { createContext, useContext, useState, useMemo } from "react";
import {
useWallets,
type UiWallet,
type UiWalletAccount
} from "@wallet-standard/react";
import { createSolanaRpc, createSolanaRpcSubscriptions } from "@solana/kit";
import { StandardConnect } from "@wallet-standard/core";
// Create RPC connection
const RPC_ENDPOINT = "https://api.devnet.solana.com";
const WS_ENDPOINT = "wss://api.devnet.solana.com";
const chain = "solana:devnet";
const rpc = createSolanaRpc(RPC_ENDPOINT);
const ws = createSolanaRpcSubscriptions(WS_ENDPOINT);
interface SolanaContextState {
// RPC
rpc: ReturnType<typeof createSolanaRpc>;
ws: ReturnType<typeof createSolanaRpcSubscriptions>;
chain: typeof chain;
// Wallet State
wallets: UiWallet[];
selectedWallet: UiWallet | null;
selectedAccount: UiWalletAccount | null;
isConnected: boolean;
// Wallet Actions
setWalletAndAccount: (
wallet: UiWallet | null,
account: UiWalletAccount | null
) => void;
}
const SolanaContext = createContext<SolanaContextState | undefined>(undefined);
export function useSolana() {
const context = useContext(SolanaContext);
if (!context) {
throw new Error("useSolana must be used within a SolanaProvider");
}
return context;
}
export function SolanaProvider({ children }: { children: React.ReactNode }) {
const allWallets = useWallets();
// Filter for Solana wallets only that support signAndSendTransaction
const wallets = useMemo(() => {
return allWallets.filter(
(wallet) =>
wallet.chains?.some((c) => c.startsWith("solana:")) &&
wallet.features.includes(StandardConnect) &&
wallet.features.includes("solana:signAndSendTransaction")
);
}, [allWallets]);
// State management
const [selectedWallet, setSelectedWallet] = useState<UiWallet | null>(null);
const [selectedAccount, setSelectedAccount] =
useState<UiWalletAccount | null>(null);
// Check if connected (account must exist in the wallet's accounts)
const isConnected = useMemo(() => {
if (!selectedAccount || !selectedWallet) return false;
// Find the wallet and check if it still has this account
const currentWallet = wallets.find((w) => w.name === selectedWallet.name);
return !!(
currentWallet &&
currentWallet.accounts.some(
(acc) => acc.address === selectedAccount.address
)
);
}, [selectedAccount, selectedWallet, wallets]);
const setWalletAndAccount = (
wallet: UiWallet | null,
account: UiWalletAccount | null
) => {
setSelectedWallet(wallet);
setSelectedAccount(account);
};
// Create context value
const contextValue = useMemo<SolanaContextState>(
() => ({
// Static RPC values
rpc,
ws,
chain,
// Dynamic wallet values
wallets,
selectedWallet,
selectedAccount,
isConnected,
setWalletAndAccount
}),
[wallets, selectedWallet, selectedAccount, isConnected]
);
return (
<SolanaContext.Provider value={contextValue}>
{children}
</SolanaContext.Provider>
);
}

Is this page helpful?

목차

페이지 편집