Master Solana Actions: Complete Setup Guide.

So you wanna master Solana Actions? Awesome choice. These things are game changers - they're basically magic links that let people sign transactions right from Twitter, Discord, anywhere. No dApps, no clunky interfaces. Just paste a link, wallet pops up, done. I've built a bunch and honestly, once you get the flow, it's stupid simple.

The core? Two endpoints. GET gives metadata (what's this action do?). POST spits out a signable transaction. That's it. Wallets like Phantom handle the rest. Sound familiar? It's like a REST API but for blockchain txns.

First things first: Project setup

Okay, grab your terminal. We're doing Next.js app router cuz it's dead easy and handles the API routes perfectly. If you're not a Next.js fan, whatever, but this is what I always use.

  1. npx create next app@latest solana actions demo - pick app router, TypeScript, Tailwind if you want styling later.
  2. cd solana actions demo
  3. npm i @solana/actions @solana/web3.js bn.js - that's your core kit.
  4. npm i -D @types/bn.js - TypeScript happiness.

Now the fun part. In your app/api/actions folder, make a route.ts. This is your Action's brain. Every Action lives at a URL like yourdomain.com/api/actions/memo.

Your first Action: Super simple memo program

Memoprogram? Yeah, writes a message on chain. Costs like 0.000005 SOL in fees. Perfect first test.

Here's the code. Copy paste this into app/api/actions/memo/route.ts. I'll explain after.

typescript import { ActionGetResponse, ActionPostRequest, ActionPostResponse, ACTIONSCORSHEADERS, createPostResponse, } from "@solana/actions"; import { createTransferInstruction, getAssociatedTokenAddress, createAssociatedTokenAccountInstruction } from "@solana/spl token"; import { Connection, PublicKey, Transaction, SystemProgram } from "@solana/web3.js"; export const runtime = "edge"; // Super fast, trust me export const GET = async (req: Request) => { const baseURL = new URL(req.url).origin; const payload: ActionGetResponse = { icon: new URL("/icon.svg", baseURL).toString(), // Make this later title: "Memo Action", description: "Write a message on Solana forever", label: "Send Memo", links: { actions: [{ type: "action", href: ${new URL(req.url)}?message=Hello%20Solana, label: "Send 'Hello Solana'", parameters: [{ name: "message", label: "Message", type: "text", required: true, }] }] } }; return Response.json(payload, { headers: ACTIONSCORSHEADERS }); }; export const OPTIONS = GET; export const POST = async (req: Request) => { try { const body: ActionPostRequest = await req.json(); const userAccount = new PublicKey(body.account); // Grab message from URL params const url = new URL(req.url); const message = url.searchParams.get("message"); if (!message) { return Response.json({ error: "No message?" }, { status: 400, headers: ACTIONSCORSHEADERS }); } const connection = new Connection("https://api.devnet.solana.com"); const transaction = new Transaction(); // Memo instruction (super cheap) transaction.add( SystemProgram.transfer({ fromPubkey: userAccount, toPubkey: userAccount, // Back to self lamports: 1000, // Tiny fee }), // Actually add memo with spl memo or just use this pattern ); transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; transaction.feePayer = userAccount; const payload: ActionPostResponse = await createPostResponse({ fields: { transaction, message: Sent memo: "${message}", }, }); return Response.json(payload, { headers: ACTIONSCORSHEADERS }); } catch (error) { return Response.json({ error: "Something broke" }, { status: 500, headers: ACTIONSCORSHEADERS }); } };

Whoa, slow down. What just happened? GET returns this JSON that wallets parse into pretty buttons. POST gets the user's wallet address, builds a txn, hands it back. Boom.

Pro tip: That ACTIONSCORSHEADERS? Magic. Wallets need it or they 403 on you. Always include.

Test this bad boy NOW

Run npm run dev. Hit http://localhost:3000/api/actions/memo in browser. See JSON? Good.

Now the real test. Go to dial.to/?action=solana action:http://localhost:3000/api/actions/memo. Wallet should pop! Click, sign, check Solscan.devnet. Memo's there. I usually freak out first time it works.

Problem? Brave Shields kill it. Turn 'em off. Or use Chrome.

Level up: Real token swap Action

Memos are cute. Let's do swaps. USDC → SOL, dynamic amounts. This is where it gets juicy.

First, add Jupiter SDK: npm i @jup ag/api.

New file: app/api/actions/swap/route.ts. Here's the goods:

typescript // Same imports + import { Jupiter } from '@jup ag/api'; export const POST = async (req: Request) => { const body: ActionPostRequest = await req.json(); const user = new PublicKey(body.account); const url = new URL(req.url); const amount = parseFloat(url.searchParams.get('amount') || '0.1'); // Default 0.1 if (amount <= 0) throw new Error('Bad amount'); const connection = new Connection('https://api.mainnet beta.solana.com'); const jupiter = await Jupiter.load({ connection, cluster: 'mainnet beta', user, }); const route = await jupiter.computeRoutes({ inputMint: new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), // USDC outputMint: new PublicKey('So11111111111111111111111111111111111111112'), // SOL amount: Math.floor(amount * 1e6), // USDC 6 decimals slippageBps: 50, // 0.5% }); const { transactions } = await jupiter.exchange({ routeInfo: route.routesInfos }); const transaction = transactions; // First one usually best transaction.feePayer = user; transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; return createPostResponse({ fields: { transaction, message: Swap ${amount} USDC → SOL } }); };

Update your GET to have swap params:

typescript parameters: [{ name: "amount", label: "USDC Amount", type: "number", required: true, }]

Test URL: dial.to/?action=solana action:http://localhost:3000/api/actions/swap?amount=1. Watch it quote live Jupiter prices. Sign. Swapped. Fees? ~0.000005 SOL + Jupiter's tiny cut.

Common gotchas (trust me, you'll hit these)

  • Blockhash too old: Always fetch fresh one in POST. Wallets picky.
  • CORS errors: ACTIONSCORSHEADERS on EVERY response.
  • Icon fails: Must be HTTPS absolute URL. SVG/PNG/WebP. No data URIs.
  • Label too long: 5 words max. Start with verb: "Swap USDC", not "Perform token swap".
  • Devnet vs Mainnet: Use devnet RPC first. mainnet.solana.com costs nothing for testing.
  • Amount parsing: Handle decimals! SOL=9, USDC=6. Use lamports.

Chaining Actions (pro move)

Want multi step? Like approve → swap → tip? Use links.next in POST response.

In POST, after success:

typescript const payload = await createPostResponse({ fields: { transaction }, links: { next: [{ type: "action", href: "/api/actions/thankyou", // Next action label: "Get Thank You NFT" }] } });

Users chain through your flow. Wild.

Deploying (don't skip this)

PlatformCommandGotchas
Vercelvercel --prodFree tier perfect. Edge runtime ftw.
Helius FlaresSpecial CLIBuilt for Actions. Auto CDN.
Cloudflare Workerswrangler deployCheapest. Global edge.

I usually Vercel. vercel.json with { "functions": { "*.ts": { "runtime": "edge" } } }. Done.

Live URL: https://your vercel.app/api/actions/memo. Share on Twitter. People click, wallets open. Viral.

Parameters deep dive

Actions shine with inputs. Types?

  • text: Freeform. Messages, addresses.
  • number: Amounts. Validates >0.
  • select: Dropdowns. options: [{label: "1 SOL", value: "1000000000"}]
  • checkbox/radio: Multiple or single choice.

Example - token selector:

typescript parameters: [{ name: "token", label: "From Token", type: "select", options: [ { label: "USDC", value: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, { label: "SOL", value: "So11111111111111111111111111111111111111112" } ] }]

URL becomes ?token=EPjFW..&amount=1. POST reads req.url.searchParams. Clean.

Production hardening

Don't be dumb. Rate limits. Input validation. Rate limits with Upstash Redis. Check user balance before big swaps.

In POST:

typescript // Pseudo rate limit const ip = req.headers.get('x forwarded for'); if (await redis.get(rate:${ip}) > 10) throw new Error('Chill'); // Balance check const balance = await connection.getBalance(user); if (balance < amount 1e9 1.1) throw new Error('Low balance');

Errors? Return { error: "Friendly message" } in JSON. Wallets show it nicely.

Bonus: Blinks everywhere

Twitter? Paste Action URL. Discord? Same. Farcaster? Native. Even embed in websites: <a href="solana action:YOUR_URL">Swap</a>.

Why does this matter? Zero UX friction. Your token launch? One link. Done. I've seen projects 10x usage just from Blinks.

Scaling to real apps

Now build vaults like Meteora. Deposit/withdraw. Use their SDKs. Same pattern:

  1. GET: Show "Deposit 1 SOL" button
  2. POST: Build vault txn with real amounts
  3. Chain to "View Position" Action

Fees always ~0.000005 SOL base + program fees. Predictable.

In my experience, start simple (memo), add swaps, then complex DeFi. You'll be shipping Actions like crazy.