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.