Token Creator
Build a complete SPL token creation tool with metadata, allowing users to create their own tokens on Solana with name, symbol, and image.
Project Overview
Text
┌─────────────────────────────────────────────────────────────────┐
│ Token Creator Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Token Information │ │
│ │ │ │
│ │ Name: [My Awesome Token________________] │ │
│ │ Symbol: [MAT___] │ │
│ │ Decimals: [9_] │ │
│ │ Initial Supply: [1000000000_____________] │ │
│ │ │ │
│ │ Image: [📷 Upload] my-token-logo.png │ │
│ │ Description: [A token for awesome things___] │ │
│ │ │ │
│ │ [ ] Freeze Authority (can freeze accounts) │ │
│ │ [ ] Mint Authority (can mint more tokens) │ │
│ │ │ │
│ │ [ Create Token ] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Result: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ✓ Token Created Successfully! │ │
│ │ │ │
│ │ Mint Address: 7xKXt...9Pqm │ │
│ │ Metadata Address: 8yLYu...4Rnm │ │
│ │ Your Balance: 1,000,000,000 MAT │ │
│ │ │ │
│ │ [View on Explorer] [Copy Address] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Setup
Bash
# Create Next.js project
bunx create-next-app@latest token-creator --typescript --tailwind --app
cd token-creator
# Install dependencies
bun add @solana/web3.js @solana/spl-token @metaplex-foundation/mpl-token-metadata \
@metaplex-foundation/umi @metaplex-foundation/umi-bundle-defaults \
@solana/wallet-adapter-react @solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets react-hook-form @hookform/resolvers zod
# Run development server
bun dev
Project Structure
Text
token-creator/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── providers.tsx
├── components/
│ ├── TokenForm.tsx
│ ├── TokenResult.tsx
│ └── ImageUpload.tsx
├── lib/
│ ├── createToken.ts
│ ├── uploadMetadata.ts
│ └── solana.ts
└── types/
└── token.ts
Token Creation Logic
TypeScript
// lib/createToken.ts
import {
Connection,
Keypair,
PublicKey,
SystemProgram,
Transaction,
} from "@solana/web3.js";
import {
TOKEN_PROGRAM_ID,
MINT_SIZE,
createInitializeMint2Instruction,
getMinimumBalanceForRentExemptMint,
getAssociatedTokenAddressSync,
createAssociatedTokenAccountInstruction,
createMintToInstruction,
} from "@solana/spl-token";
import {
createCreateMetadataAccountV3Instruction,
PROGRAM_ID as METADATA_PROGRAM_ID,
} from "@metaplex-foundation/mpl-token-metadata";
export interface TokenConfig {
name: string;
symbol: string;
decimals: number;
initialSupply: number;
metadataUri: string;
mintAuthority: boolean;
freezeAuthority: boolean;
}
export interface TokenResult {
mintAddress: string;
metadataAddress: string;
tokenAccount: string;
signature: string;
}
export async function createToken(
connection: Connection,
payer: PublicKey,
signTransaction: (tx: Transaction) => Promise<Transaction>,
config: TokenConfig,
): Promise<TokenResult> {
// Generate new mint keypair
const mintKeypair = Keypair.generate();
// Get rent exemption
const lamports = await getMinimumBalanceForRentExemptMint(connection);
// Get associated token account address
const associatedTokenAccount = getAssociatedTokenAddressSync(
mintKeypair.publicKey,
payer,
);
// Derive metadata PDA
const [metadataPDA] = PublicKey.findProgramAddressSync(
[
Buffer.from("metadata"),
METADATA_PROGRAM_ID.toBuffer(),
mintKeypair.publicKey.toBuffer(),
],
METADATA_PROGRAM_ID,
);
// Build transaction
const transaction = new Transaction();
// 1. Create mint account
transaction.add(
SystemProgram.createAccount({
fromPubkey: payer,
newAccountPubkey: mintKeypair.publicKey,
space: MINT_SIZE,
lamports,
programId: TOKEN_PROGRAM_ID,
}),
);
// 2. Initialize mint
transaction.add(
createInitializeMint2Instruction(
mintKeypair.publicKey,
config.decimals,
payer, // Mint authority
config.freezeAuthority ? payer : null, // Freeze authority
TOKEN_PROGRAM_ID,
),
);
// 3. Create associated token account
transaction.add(
createAssociatedTokenAccountInstruction(
payer,
associatedTokenAccount,
payer,
mintKeypair.publicKey,
),
);
// 4. Mint initial supply
const mintAmount =
BigInt(config.initialSupply) * BigInt(10 ** config.decimals);
transaction.add(
createMintToInstruction(
mintKeypair.publicKey,
associatedTokenAccount,
payer,
mintAmount,
),
);
// 5. Create metadata account
transaction.add(
createCreateMetadataAccountV3Instruction(
{
metadata: metadataPDA,
mint: mintKeypair.publicKey,
mintAuthority: payer,
payer: payer,
updateAuthority: payer,
},
{
createMetadataAccountArgsV3: {
data: {
name: config.name,
symbol: config.symbol,
uri: config.metadataUri,
sellerFeeBasisPoints: 0,
creators: null,
collection: null,
uses: null,
},
isMutable: true,
collectionDetails: null,
},
},
),
);
// Set recent blockhash and fee payer
transaction.recentBlockhash = (
await connection.getLatestBlockhash()
).blockhash;
transaction.feePayer = payer;
// Partially sign with mint keypair
transaction.partialSign(mintKeypair);
// User signs with wallet
const signedTx = await signTransaction(transaction);
// Send transaction
const signature = await connection.sendRawTransaction(signedTx.serialize());
// Confirm transaction
await connection.confirmTransaction(signature, "confirmed");
return {
mintAddress: mintKeypair.publicKey.toBase58(),
metadataAddress: metadataPDA.toBase58(),
tokenAccount: associatedTokenAccount.toBase58(),
signature,
};
}
Metadata Upload
TypeScript
// lib/uploadMetadata.ts
export interface TokenMetadata {
name: string;
symbol: string;
description: string;
image: string; // URL to image
}
// Upload to a simple file storage (in production, use Arweave/IPFS)
export async function uploadMetadata(metadata: TokenMetadata): Promise<string> {
// Option 1: Using NFT.Storage (free, IPFS-based)
const response = await fetch("https://api.nft.storage/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_NFT_STORAGE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(metadata),
});
const data = await response.json();
return `https://nftstorage.link/ipfs/${data.value.cid}`;
}
// Option 2: Local/mock for development
export async function uploadMetadataLocal(
metadata: TokenMetadata,
): Promise<string> {
// Store metadata temporarily (for development only)
// In production, always use decentralized storage
// Using a simple JSON hosting service
const response = await fetch("https://api.jsonbin.io/v3/b", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Master-Key": process.env.NEXT_PUBLIC_JSONBIN_KEY || "",
},
body: JSON.stringify(metadata),
});
const data = await response.json();
return `https://api.jsonbin.io/v3/b/${data.metadata.id}`;
}
// Upload image to storage
export async function uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.nft.storage/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_NFT_STORAGE_KEY}`,
},
body: formData,
});
const data = await response.json();
return `https://nftstorage.link/ipfs/${data.value.cid}`;
}
Token Creation Form
TypeScript
// components/TokenForm.tsx
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { createToken, TokenResult } from "@/lib/createToken";
import { uploadMetadata, uploadImage } from "@/lib/uploadMetadata";
import { ImageUpload } from "./ImageUpload";
const tokenSchema = z.object({
name: z.string().min(1, "Name is required").max(32, "Max 32 characters"),
symbol: z.string().min(1, "Symbol is required").max(10, "Max 10 characters"),
decimals: z.number().min(0).max(9).default(9),
initialSupply: z.number().min(1, "Supply must be at least 1"),
description: z.string().max(200, "Max 200 characters").optional(),
mintAuthority: z.boolean().default(true),
freezeAuthority: z.boolean().default(false),
});
type TokenFormData = z.infer<typeof tokenSchema>;
interface TokenFormProps {
onSuccess: (result: TokenResult) => void;
}
export function TokenForm({ onSuccess }: TokenFormProps) {
const { connection } = useConnection();
const { publicKey, signTransaction } = useWallet();
const [loading, setLoading] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TokenFormData>({
resolver: zodResolver(tokenSchema),
defaultValues: {
decimals: 9,
initialSupply: 1000000000,
mintAuthority: true,
freezeAuthority: false,
},
});
const onSubmit = async (data: TokenFormData) => {
if (!publicKey || !signTransaction) {
setError("Please connect your wallet");
return;
}
setLoading(true);
setError(null);
try {
// 1. Upload image if provided
let imageUrl = "";
if (imageFile) {
imageUrl = await uploadImage(imageFile);
}
// 2. Upload metadata
const metadataUri = await uploadMetadata({
name: data.name,
symbol: data.symbol,
description: data.description || "",
image: imageUrl,
});
// 3. Create token
const result = await createToken(
connection,
publicKey,
signTransaction,
{
name: data.name,
symbol: data.symbol,
decimals: data.decimals,
initialSupply: data.initialSupply,
metadataUri,
mintAuthority: data.mintAuthority,
freezeAuthority: data.freezeAuthority,
}
);
onSuccess(result);
} catch (err) {
console.error("Token creation failed:", err);
setError(err instanceof Error ? err.message : "Token creation failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Token Name *
</label>
<input
{...register("name")}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="My Awesome Token"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2">
Symbol *
</label>
<input
{...register("symbol")}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 uppercase"
placeholder="MAT"
maxLength={10}
/>
{errors.symbol && (
<p className="text-red-500 text-sm mt-1">{errors.symbol.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Decimals
</label>
<input
type="number"
{...register("decimals", { valueAsNumber: true })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
min={0}
max={9}
/>
<p className="text-gray-500 text-xs mt-1">
9 is standard (like SOL)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Initial Supply *
</label>
<input
type="number"
{...register("initialSupply", { valueAsNumber: true })}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
min={1}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Description
</label>
<textarea
{...register("description")}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Describe your token..."
maxLength={200}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Token Image
</label>
<ImageUpload
onImageSelect={setImageFile}
selectedImage={imageFile}
/>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
{...register("mintAuthority")}
className="w-4 h-4 rounded"
/>
<div>
<p className="font-medium">Keep Mint Authority</p>
<p className="text-sm text-gray-500">
Allows minting more tokens in the future
</p>
</div>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
{...register("freezeAuthority")}
className="w-4 h-4 rounded"
/>
<div>
<p className="font-medium">Enable Freeze Authority</p>
<p className="text-sm text-gray-500">
Allows freezing token accounts (use with caution)
</p>
</div>
</label>
</div>
<button
type="submit"
disabled={loading || !publicKey}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium
hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed
transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Creating Token...
</span>
) : (
"Create Token"
)}
</button>
</form>
);
}
Image Upload Component
TypeScript
// components/ImageUpload.tsx
"use client";
import { useCallback, useState } from "react";
interface ImageUploadProps {
onImageSelect: (file: File | null) => void;
selectedImage: File | null;
}
export function ImageUpload({ onImageSelect, selectedImage }: ImageUploadProps) {
const [preview, setPreview] = useState<string | null>(null);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file
if (!file.type.startsWith("image/")) {
alert("Please select an image file");
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("Image must be less than 5MB");
return;
}
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
onImageSelect(file);
}
},
[onImageSelect]
);
const handleRemove = useCallback(() => {
setPreview(null);
onImageSelect(null);
}, [onImageSelect]);
return (
<div className="border-2 border-dashed rounded-lg p-4">
{preview ? (
<div className="relative">
<img
src={preview}
alt="Token preview"
className="w-24 h-24 object-cover rounded-lg mx-auto"
/>
<button
type="button"
onClick={handleRemove}
className="absolute top-0 right-0 -mt-2 -mr-2 bg-red-500 text-white
rounded-full w-6 h-6 flex items-center justify-center"
>
×
</button>
<p className="text-center text-sm text-gray-500 mt-2">
{selectedImage?.name}
</p>
</div>
) : (
<label className="cursor-pointer block text-center">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<div className="py-8">
<span className="text-4xl">📷</span>
<p className="mt-2 text-gray-500">
Click to upload image
</p>
<p className="text-xs text-gray-400">
PNG, JPG up to 5MB
</p>
</div>
</label>
)}
</div>
);
}
Token Result Display
TypeScript
// components/TokenResult.tsx
"use client";
import { TokenResult } from "@/lib/createToken";
interface TokenResultProps {
result: TokenResult;
onReset: () => void;
}
export function TokenResult({ result, onReset }: TokenResultProps) {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
// Show toast notification
};
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-center gap-3 mb-4">
<span className="text-3xl">✅</span>
<h3 className="text-xl font-bold text-green-800">
Token Created Successfully!
</h3>
</div>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600">Mint Address</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white px-3 py-2 rounded border font-mono text-sm">
{result.mintAddress}
</code>
<button
onClick={() => copyToClipboard(result.mintAddress)}
className="px-3 py-2 bg-gray-100 rounded hover:bg-gray-200"
>
📋
</button>
</div>
</div>
<div>
<label className="text-sm text-gray-600">Metadata Address</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white px-3 py-2 rounded border font-mono text-sm truncate">
{result.metadataAddress}
</code>
<button
onClick={() => copyToClipboard(result.metadataAddress)}
className="px-3 py-2 bg-gray-100 rounded hover:bg-gray-200"
>
📋
</button>
</div>
</div>
<div>
<label className="text-sm text-gray-600">Your Token Account</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white px-3 py-2 rounded border font-mono text-sm truncate">
{result.tokenAccount}
</code>
<button
onClick={() => copyToClipboard(result.tokenAccount)}
className="px-3 py-2 bg-gray-100 rounded hover:bg-gray-200"
>
📋
</button>
</div>
</div>
<div className="flex gap-3 mt-6">
<a
href={`https://explorer.solana.com/address/${result.mintAddress}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg text-center
hover:bg-blue-700 transition-colors"
>
View on Explorer
</a>
<button
onClick={onReset}
className="flex-1 py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200
transition-colors"
>
Create Another
</button>
</div>
</div>
</div>
);
}
Main Page
TypeScript
// app/page.tsx
"use client";
import { useState } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { TokenForm } from "@/components/TokenForm";
import { TokenResult } from "@/components/TokenResult";
import type { TokenResult as TokenResultType } from "@/lib/createToken";
export default function TokenCreatorPage() {
const { connected } = useWallet();
const [result, setResult] = useState<TokenResultType | null>(null);
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
<header className="border-b bg-white/80 backdrop-blur-sm sticky top-0">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">🪙 Token Creator</h1>
<WalletMultiButton />
</div>
</header>
<main className="max-w-2xl mx-auto px-4 py-12">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold mb-2">
Create Your SPL Token
</h2>
<p className="text-gray-600">
Launch your own token on Solana in minutes
</p>
</div>
{!connected ? (
<div className="text-center py-12 bg-white rounded-xl shadow-sm">
<p className="text-gray-500 mb-4">
Connect your wallet to create a token
</p>
<WalletMultiButton />
</div>
) : result ? (
<TokenResult
result={result}
onReset={() => setResult(null)}
/>
) : (
<div className="bg-white rounded-xl shadow-sm p-6">
<TokenForm onSuccess={setResult} />
</div>
)}
<div className="mt-8 text-center text-sm text-gray-500">
<p>Estimated cost: ~0.01 SOL (rent + fees)</p>
<p>Network: Devnet</p>
</div>
</main>
</div>
);
}
What You'll Learn
- Creating SPL tokens programmatically
- Setting up mint and metadata accounts
- Working with Metaplex token metadata
- File upload and metadata hosting
- Form validation with React Hook Form + Zod
- Transaction building and signing
Next: PDA Counter - Build an on-chain counter program.