Skip to main content
Solana Blinks Guide: Ship an Action in 5 Steps - Featured image

Solana Blinks Guide: Ship an Action in 5 Steps

BUILDERZ

BUILDERZ

Share

In this guide

The decisions and working steps ahead.

Where Blinks render in 2026

Step 1 and 2: the manifest and the GET endpoint

The manifest at the domain root

You paste a Blink into X and wait for the buttons. A plain URL shows up instead.

Your wallet is Phantom. Phantom's developer docs now say Blink buttons no longer trigger transactions there, with no plans to resume.

The Action itself still works. The open question is where it renders.

Here is the current spec, the five pieces you build, and an honest map of which clients unfurl a Blink in 2026. Keep the curl checklist near the end; it catches most launch-day failures before the registry email.

A Solana Action is a plain HTTPS API. It answers a GET with metadata and a POST with a transaction to sign. A Blink is any client that reads that API and draws buttons for it, per the Solana Actions spec.

The client layer is where the picture has shifted since the 2024 launch.

  • Phantom has discontinued Blink support. Its docs say to integrate directly instead.
  • Dialect's July 2025 Blinks Client post listed the Backpack and OKX extensions as rendering Blinks on X. We could not find a 2026 statement from either wallet, so treat that as unconfirmed rather than dead.
  • Dialect's own React client and Chrome extension observer render Blinks on any site you control, including a Twitter observer for extensions you ship.
  • dial.to renders every Blink as an interstitial page and shows its registry status, according to the Dialect registry docs.

The practical reading: build the Action, embed the Blink in your own surfaces with Dialect's client, and treat X unfurling as a bonus you don't depend on. That's how we scoped it on SEND FOMO, a Solana key game where a Blink integration shortened the mint and burn path. The game didn't need X to unfurl. It needed one click from a link.

When is a blink the wrong tool? When the flow needs more than one input screen, a login, or a cart. The spec supports chaining through a next link, but a client draws one card at a time. If your checkout has three pages, build a page and link to it.

Step 1 and 2: the manifest and the GET endpoint

The code below targets @solana/actions 1.6.6, the latest tag on the package registry as of today. It depends on @solana/web3.js 1.x, so keep the 1.x line for these routes even if the rest of your app uses Kit.

The manifest at the domain root

The actions.json file maps the URLs people share to the API paths that serve the Action. The spec requires it at the root of the domain, CORS-accessible.

Code examplejson
{
  "rules": [
    { "pathPattern": "/donate", "apiPath": "/api/actions/donate" },
    { "pathPattern": "/api/actions/**", "apiPath": "/api/actions/**" }
  ]
}

Query parameters on the shared URL carry over to the mapped API path. That lets /donate?amount=1 reach your handler unchanged.

The GET response

GET returns what the client draws: an icon, a title, a description, and either one label or a list of linked actions. The spec says the icon must be an absolute HTTPS URL to an SVG, PNG, or WebP, and labels should start with a verb and stay under five words.

Code exampletypescript
// app/api/actions/donate/route.ts (Next.js App Router), part 1 of 2
import {
  ActionGetResponse,
  ActionPostRequest,
  ActionPostResponse,
  createActionHeaders,
  createPostResponse,
} from "@solana/actions";
import {
  Connection,
  LAMPORTS_PER_SOL,
  PublicKey,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";

const headers = createActionHeaders({ chainId: "mainnet", actionVersion: "2.4" });
const TREASURY = new PublicKey(process.env.TREASURY_PUBKEY!);
const connection = new Connection(process.env.RPC_URL!, "confirmed");

export async function GET() {
  const payload: ActionGetResponse = {
    type: "action",
    icon: "https://example.com/donate.png",
    title: "Support the project",
    description: "Send SOL to the treasury in one click.",
    label: "Donate",
    links: {
      actions: [
        { type: "transaction", label: "Donate 0.1 SOL", href: "/api/actions/donate?amount=0.1" },
        {
          type: "transaction",
          label: "Donate custom",
          href: "/api/actions/donate?amount={amount}",
          parameters: [{ name: "amount", label: "SOL amount", type: "number", min: 0.01, required: true }],
        },
      ],
    },
  };
  return Response.json(payload, { headers });
}

export const OPTIONS = GET; // preflight for the whole route, POST included

When the linked actions list is present, the root label is not drawn as a button. Use the parameters block for inputs; the spec supports text, number, email, URL, date, checkbox, radio, select, and textarea types.

Step 3: the POST endpoint returns the transaction

The client sends a POST with one field, the base58 public key of the signer, to the same URL. You build the transaction, serialize it, and return it base64-encoded. The spec's POST response has three fields: the transaction, an optional message, and an optional next link for chaining.

The SDK's post-response helper does the serialization and can partial-sign for you. Validate the amount before you touch an RPC. This handler goes in the same route file as the GET above and reuses its imports, headers, and connection.

Errors go back as JSON with a message field and a 4xx status. The spec allows a non-fatal error object on GET too, which lets you disable a button and explain why without breaking the card.

Code exampletypescript
// app/api/actions/donate/route.ts, part 2 of 2: same file, below the GET handler
export async function POST(req: Request) {
  const amount = Number(new URL(req.url).searchParams.get("amount"));
  if (!Number.isFinite(amount) || amount < 0.01 || amount > 100) {
    return Response.json({ message: "Amount must be 0.01 to 100 SOL" }, { status: 400, headers });
  }

  const body: ActionPostRequest = await req.json();
  let payer: PublicKey;
  try {
    payer = new PublicKey(body.account);
  } catch {
    return Response.json({ message: "Invalid account" }, { status: 400, headers });
  }

  const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
  const tx = new Transaction({ feePayer: payer, blockhash, lastValidBlockHeight }).add(
    SystemProgram.transfer({
      fromPubkey: payer,
      toPubkey: TREASURY,
      lamports: Math.round(amount * LAMPORTS_PER_SOL),
    }),
  );

  // createPostResponse serializes and base64-encodes the transaction
  const payload: ActionPostResponse = await createPostResponse({
    fields: { type: "transaction", transaction: tx, message: `Donating ${amount} SOL` },
  });
  return Response.json(payload, { headers });
}

Two things bite teams here. The wallet, not your server, signs and sends, so never assume the transaction landed because you returned it. And a blockhash is only good for 150 blocks. Since slots dropped to 200ms on October 9, 2026, that window is about 30 seconds, per the reduced slot times upgrade page. A user who idles on the preview gets a stale transaction, so return a plain error and let the client refetch.

Step 4: CORS headers and the identity memo

Every Action route, plus the manifest, must answer OPTIONS and return an open allow-origin header. The spec lists GET, POST, PUT, and OPTIONS as the allowed methods, and the content type, authorization, and encoding headers as the minimum allowed set.

Version 1.6.6 of the SDK adds two more pairs. The exported header constant accepts the client's version and chain headers and exposes the server's own two back. The header helper fills them from the CAIP chain IDs the package ships.

Code exampletext
Access-Control-Allow-Headers:  ... X-Accept-Action-Version, X-Accept-Blockchain-Ids
Access-Control-Expose-Headers: X-Action-Version, X-Blockchain-Ids
chainId "mainnet" -> solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp
chainId "devnet"  -> solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1

Set the open origin only on these routes; the SDK's own source comment warns against opening other routes. Express-style frameworks get the same headers from the exported middleware:

Code exampletypescript
import { actionCorsMiddleware } from "@solana/actions";
app.use("/api/actions", actionCorsMiddleware({ chainId: "mainnet", actionVersion: "2.4" }));

Action identity

The spec defines an optional identity so you can attribute transactions to your Action on-chain. It's a key pair that signs a memo, not the transaction. The reference is a one-time 32-byte key, and the signature covers only that reference.

Code exampletypescript
import { getActionIdentityFromEnv, createPostResponse } from "@solana/actions";
import { Keypair } from "@solana/web3.js";

// memo text: solana-action:<identity>:<reference>:<signature>
const actionIdentity = getActionIdentityFromEnv("ACTION_IDENTITY_SECRET");
const reference = Keypair.generate().publicKey;

const payload = await createPostResponse({
  fields: { type: "transaction", transaction: tx },
  actionIdentity,
  reference,
});
// later: fetch signatures for the identity address, then
// validateActionIdentifierMemo() on each transaction

The SDK appends the memo instruction and adds the identity and reference as read-only non-signer keys. Querying signatures for the identity address later gives you every transaction your Blink produced. Keep the secret in your server environment, never in the repo.

Step 5: register, test, then decide where to embed

Dialect runs the registry that clients consult for trust. Its registry page says review is manual today: you email hello@dialect.to with a working Action, a clear description, test coverage, and contact details. An Action is trusted, unregistered, or blocked. By default, Dialect's client refuses to execute anything that isn't trusted, and wallets have chosen not to render unregistered or blocked Actions at all.

Before you email, run this checklist on the deployed domain, not localhost.

Code examplebash
# manifest is public and CORS-open
curl -si https://example.com/actions.json | grep -iE 'HTTP/|access-control|content-type'

# GET returns metadata with the version headers
curl -si https://example.com/api/actions/donate?amount=0.1 | grep -iE 'HTTP/|x-action|x-blockchain'

# OPTIONS preflight answers 200
curl -si -X OPTIONS https://example.com/api/actions/donate

# POST returns a base64 transaction for a real public key
curl -s -X POST https://example.com/api/actions/donate?amount=0.1 \
  -H 'Content-Type: application/json' \
  -d '{"account":"<your public key>"}' | head -c 200

Then paste the URL into dial.to. Dialect's client docs recommend it for testing unfurl behavior, and it shows the registry badge you'll get elsewhere.

For your own product, skip the extension lottery and render the Blink where you already own the page. Install @dialectlabs/blinks (0.22.5 on the package registry today), wrap it in the standard wallet providers, and mount the component as the component docs show:

Code exampletsx
import "@dialectlabs/blinks/index.css";
import { Blink, useBlink } from "@dialectlabs/blinks";
import { useBlinkSolanaWalletAdapter } from "@dialectlabs/blinks/hooks/solana";

const { adapter } = useBlinkSolanaWalletAdapter(RPC_URL);
const { blink, isLoading } = useBlink({ url: "https://example.com/api/actions/donate" });
if (!isLoading && blink) return <Blink blink={blink} adapter={adapter} />;

A checkout button on a merch page is the same shape. The Bonk Merch Shop we built is a Next.js store with crypto payment support. A Blink fits that kind of page the same way: the transaction is one click from the link, and you control the surface it appears on.

If you want the Action designed, built, and tested against this spec, that's part of our Solana development service. The Next.js routes and the front end fall under web development.

FAQ

Not in Phantom, which has ended support according to its own docs. Backpack's extension was listed as rendering Blinks on X in Dialect's July 2025 post, and we found no newer confirmation or retraction. Plan for X unfurling as optional, and embed the blink on surfaces you own.

Must an action be registered with Dialect to work?

No. The API works for any client that calls it, and dial.to renders unregistered Blinks with a warning. Registration moves you to trusted, which is what Dialect's client allows by default and what wallets required before rendering a blink inline.

Can I use Kit instead of web3.js 1.x for the endpoint?

The 1.6.6 helpers take a legacy or versioned web3.js 1.x transaction. You can build with Kit and serialize to base64 yourself, since the spec only cares about the wire format. We keep the Action routes on web3.js 1.x to use the post-response and identity helpers.

What should the POST endpoint never do?

Never send the transaction, never trust the account field without parsing it as a public key, and never build the transaction before validating inputs. The client signs and submits. Your server only prepares and attributes.

Use the action identity. Each transaction carries a memo signed by your identity key pair over a one-time reference, and the SDK ships helpers that verify the memo and the signature. Querying the identity address gives you the full history.

Ship the Action, choose the surface

The spec is stable, and the SDK is small. The hard decision is where your Blink needs to render, and in 2026 that answer is your own pages first.

Try the curl checklist on your deployed domain before you email the registry. Want an Action built and tested against the current spec before you promise a one-click flow to users? Talk to us about your Solana build.

Continue reading

Shipping a web or product surface?

Name the critical path, ownership, and analytics up front, then ship it against acceptance criteria.

Web development services