Skip to main content

Build a Solana Kit Plugin for Quicknode Priority Fees API

Updated on
Mar 20, 2026

17 min read

Overview

Solana Kit is the modern JavaScript/TypeScript SDK developed by Anza for building Solana applications. It replaces the legacy @solana/web3.js library with a ground-up rewrite focused on modularity, type safety, and performance.

Another advantage of Kit is the ability to extend the client with custom functionality. Instead of wiring up custom transports or one-off helper functions, Kit's plugin system lets you define a capability once and attach it to any client in a single .use() call.

This guide walks through building a Kit plugin that wraps Quicknode's Solana Priority Fee API. The end result is a self-contained plugin to get priority fees and a working example that sends a SOL transfer with a dynamically-set priority fee.


TL;DR
  • What you'll build: A reusable quicknodePriorityFees Kit plugin that fetches fee estimates from Quicknode and exposes them as typed client methods
  • What you'll learn: How Kit's plugin pattern works and how to wire a live fee estimate into the transaction planner
  • End result: A working script that retrieves all fee tiers and sends a SOL transfer with a dynamically-set priority fee
  • Time: ~30 minutes

What You Will Need


  • Familiarity with Solana Kit
  • Node.js 22 or higher
  • TypeScript experience
  • A Quicknode Solana mainnet-beta endpoint with the Priority Fee API add-on enabled. If you don't have one, create a free account here.

Dependencies Used in this Guide

DependencyVersion
@solana/kit^6.1.0
@solana/kit-plugin-instruction-plan^0.6.0
@solana/kit-plugin-payer^0.6.0
@solana/kit-plugin-rpc^0.6.0
@solana-program/system^0.12.0

How Do Kit Plugins Work?

A Kit plugin is a curried function: instead of taking all its arguments at once, it takes them one group at a time. The outer function accepts a configuration object and returns a new function, which then accepts the client. This lets you configure the plugin once at setup time and reuse the resulting function across multiple clients.

const myPlugin = (config) => (client) => ({ ...client, newMethod: ... });

There are three distinct layers:


  1. Config: The outer call accepts the plugin's options (URLs, defaults, feature flags) at setup time.
  2. Client: The inner call receives the current client object, which holds everything already attached by prior .use() calls (RPC, payer, etc.).
  3. Return value: A new object that spreads the existing client and adds new properties (methods, values). TypeScript infers the combined type automatically.

.use() chains these transforms together. Each plugin receives the output of the previous one, so by the time your code calls a method, the full chain has run:

const client = createEmptyClient()
.use(rpc(ENDPOINT)) // adds client.rpc
.use(myPlugin({ option: 'value' })); // receives { rpc }, returns { rpc, newMethod }

client.newMethod(); // fully typed, no casting

This is the same pattern used by all of the official Solana Kit Plugins packages. Any plugin is a standard npm package that is published once and composable alongside any other plugin in the ecosystem.

Build the Plugin

The plugin retrieves priority fee estimates from Quicknode's Solana Priority Fee API and automatically applies them to transactions. The example script uses a basic SOL transfer to keep things simple, but the same plugin and approach works with any transaction type — DEX swaps, NFT mints, or program calls.

Create a New Project

mkdir qn-priority-fees-plugin && cd qn-priority-fees-plugin
npm init -y

Install dependencies:

npm install @solana/kit @solana/kit-plugin-instruction-plan @solana/kit-plugin-payer @solana/kit-plugin-rpc @solana-program/system

Install dev dependencies:

npm install --save-dev typescript tsx @types/node

Add a tsconfig.json. This isn't required by tsx to run the example, but needed to compile or publish the plugin to the npm registry.

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

Create the source directory and index.ts file:

mkdir src
touch src/index.ts

Define the Types

The plugin lives in a single file src/index.ts that exports the plugin function and all its TypeScript types.

src/index.ts
import {
type GetEpochInfoApi,
type GetLatestBlockhashApi,
type GetSignatureStatusesApi,
type MicroLamports,
type Rpc,
type RpcSubscriptions,
type SendTransactionApi,
type SignatureNotificationsApi,
type SimulateTransactionApi,
type SlotNotificationsApi,
type TransactionPlanner,
type TransactionSigner,
} from "@solana/kit";
import { rpcTransactionPlanner } from "@solana/kit-plugin-rpc";

/** The fee tier to select from a PriorityFeeEstimate. */
export enum PriorityFeeTier {
Low = "low",
Medium = "medium",
High = "high",
Extreme = "extreme",
Recommended = "recommended",
}

/** Per-level breakdown for one dimension of the estimate. */
export type PriorityFeeLevels = {
low: number;
medium: number;
high: number;
extreme: number;
percentiles: Record<string, number>;
};

/** Full response shape from qn_estimatePriorityFees (api_version: 2) */
export type PriorityFeeEstimate = {
context: { slot: number };
per_compute_unit: PriorityFeeLevels;
per_transaction: PriorityFeeLevels;
recommended: number;
};

/** Parameters accepted by qn_estimatePriorityFees */
export type EstimatePriorityFeesParams = {
/** Program or account address to scope the estimate to */
account?: string;
/** Number of recent blocks to analyse (default: 100) */
last_n_blocks?: number;
/** API version — use 2 for the full response shape above */
api_version?: number;
};

/** Configuration for the quicknodePriorityFees plugin */
export type QuicknodePriorityFeesConfig = {
/**
* Your Quicknode endpoint URL.
* The endpoint must have the Priority Fee API add-on enabled.
*/
url: string;
/**
* Optional default params merged into every estimatePriorityFees call.
* Call-time params override these defaults.
*/
defaults?: EstimatePriorityFeesParams;
};

/** Configuration for the quicknodeTransactionPlanner plugin */
export type QuicknodeTransactionPlannerConfig = {
/** Fee tier to fetch on every transaction. Default: 'recommended' */
tier?: PriorityFeeTier;
/**
* The transaction signer who will pay for fees.
* Alternatively, set `payer` on the client before applying this plugin.
*/
payer?: TransactionSigner;
/** Optional callback invoked with the priority fee (in micro-lamports) used for each transaction. */
onFee?: (fee: MicroLamports) => void;
};

type RpcRequirements = Rpc<
GetEpochInfoApi &
GetLatestBlockhashApi &
GetSignatureStatusesApi &
SendTransactionApi &
SimulateTransactionApi
>;

type RpcSubscriptionsRequirements = RpcSubscriptions<
SignatureNotificationsApi & SlotNotificationsApi
>;

export type QuicknodePriorityFeesExtension = {
estimatePriorityFees: (
params?: EstimatePriorityFeesParams,
) => Promise<PriorityFeeEstimate>;
getPriorityFee: (
tier?: PriorityFeeTier,
params?: EstimatePriorityFeesParams,
) => Promise<MicroLamports>;
};

PriorityFeeEstimate reflects the Priority Fees API v2 response, which returns separate per_compute_unit and per_transaction breakdowns plus a top-level recommended value.

MicroLamports is Kit's branded bigint for per-compute-unit fees. The transaction planner accepts this type directly via its priorityFees option, so returning it from the plugin means zero glue code at the call site.


Get Priority Fees

Still in src/index.ts, add the quicknodePriorityFees function to get priority fees by tier:

src/index.ts
/**
* Plugin that adds Quicknode priority fee estimation to a Solana Kit client.
*
* @see https://www.quicknode.com/docs/solana/qn_estimatePriorityFees
*/
export function quicknodePriorityFees(config: QuicknodePriorityFeesConfig) {
return function <TClient extends object>(
client: TClient,
): TClient & QuicknodePriorityFeesExtension {
const estimatePriorityFees = async (
params?: EstimatePriorityFeesParams,
): Promise<PriorityFeeEstimate> => {
const mergedParams: EstimatePriorityFeesParams = {
last_n_blocks: 100,
api_version: 2,
...config.defaults,
...params,
};

const response = await fetch(config.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: crypto.randomUUID(),
method: "qn_estimatePriorityFees",
params: mergedParams,
}),
});

if (!response.ok) {
throw new Error(
`qn_estimatePriorityFees HTTP error: ${response.status} ${response.statusText}`,
);
}

const json = (await response.json()) as {
result?: PriorityFeeEstimate;
error?: { code: number; message: string };
};

if (json.error) {
throw new Error(
`qn_estimatePriorityFees RPC error ${json.error.code}: ${json.error.message}`,
);
}

if (!json.result) {
throw new Error("qn_estimatePriorityFees returned an empty result");
}

return json.result;
};

const getPriorityFee = async (
tier: PriorityFeeTier = PriorityFeeTier.Recommended,
params?: EstimatePriorityFeesParams,
): Promise<MicroLamports> => {
const estimate = await estimatePriorityFees(params);
const fee =
tier === PriorityFeeTier.Recommended
? estimate.recommended
: estimate.per_compute_unit[tier];
return BigInt(Math.ceil(fee)) as MicroLamports;
};

return { ...client, estimatePriorityFees, getPriorityFee };
};
}

Here's what each part does:

estimatePriorityFees fetches a fee estimate from the Priority Fee API. Any params passed at call time are merged on top of the defaults set during plugin setup, so per-call overrides always win. Use this when you want the full response. For example, to display all fee tiers so users can choose their own priority level.

Expected result:

{
context: { slot: 402491299 },
per_compute_unit: {
extreme: 2000000,
high: 535996,
low: 25172,
medium: 176531,
percentiles: {
'0': 1,
'5': 5,
'10': 1864,
[...],
'100': 14265640
}
},
per_transaction: {
extreme: 401724596244,
high: 99999974109,
low: 4561710418,
medium: 36171896086,
percentiles: {
'0': 62567,
'5': 485035,
'10': 943903818,
[...],
'100': 10000000000000
}
},
recommended: 1142471
}

In most cases, you'll use per_compute_unit. Solana's transaction fee model charges priority fees per compute unit consumed, and the transaction planner sets the fee with a SetComputeUnitPrice instruction that expects a per-compute-unit value (in micro-lamports). Using per_transaction values here would result in over-paying.

getPriorityFee calls estimatePriorityFees and returns a single fee value for the requested tier, typed as MicroLamports so it can be passed directly to the transaction planner. Use this when you just need a fee to plug into a transaction and don't need the full breakdown.

The generic <TClient extends object> is the standard Kit plugin constraint. The plugin spreads the incoming client and adds new properties. TypeScript infers the combined type automatically, so downstream .use() calls and property accesses remain fully typed regardless of what else is already on the client.

Add Transaction Planner

Add the quicknodeTransactionPlanner function to fetch and apply a fresh priority fee using the Quicknode Priority Fee API on every transaction:

src/index.ts
/**
* Plugin that wraps the default Solana Kit transaction planner to fetch a fresh
* priority fee from Quicknode on every transaction.
*/
export function quicknodeTransactionPlanner(
config?: QuicknodeTransactionPlannerConfig,
) {
return function <
TClient extends object & {
getPriorityFee: QuicknodePriorityFeesExtension["getPriorityFee"];
rpc: RpcRequirements;
rpcSubscriptions: RpcSubscriptionsRequirements;
payer?: TransactionSigner;
},
>(client: TClient) {
const payer = config?.payer ?? client.payer;

const transactionPlanner: TransactionPlanner = async (
instructionPlan,
plannerConfig,
) => {
const priorityFees = await client.getPriorityFee(
config?.tier ?? PriorityFeeTier.Recommended,
);
config?.onFee?.(priorityFees);

const { transactionPlanner: inner } = rpcTransactionPlanner({
priorityFees,
payer,
})(client);
return inner(instructionPlan, plannerConfig);
};

return { ...client, transactionPlanner };
};
}

transactionPlanner is a custom wrapper around the standard Kit planner. Each time it is invoked, it calls client.getPriorityFee() to fetch a fresh fee from the Priority Fee API, then calls rpcTransactionPlanner with that fee to build an inner planner that applies it. If an onFee callback is provided, it fires with the fee value — useful for logging or analytics — before planning begins.

This plugin only sets transactionPlanner on the client. The executor (rpcTransactionPlanExecutor) is added separately in your .use() chain, so each concern stays in its own plugin.

Use quicknodeTransactionPlanner when you want fees fetched and applied automatically on every send without any manual wiring at the call site.

Use the Plugin

The example script demonstrates two things: fetching the full fee estimate to inspect all available tiers, and sending a transaction with a specific tier applied.

Create a Local Wallet

Before running the example, create a dedicated local keypair for this project:

solana-keygen new --outfile ~/.config/solana/kit-plugin-id.json

Then fund it with at least 0.003 SOL to cover the transfer amount and transaction fees.

Set Up Environment Variables

Create a .env file in the project root with your credentials:

.env
# Use your Quicknode endpoint with Priority Fees API enabled here
QUICKNODE_ENDPOINT=https://qn-demo-endpoint.quiknode.pro/abcd1234
JUPITER_PROGRAM=JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4
PAYER_KEYPAIR_PATH=/path/to/keypair/kit-plugin-id.json
RECIPIENT_ADDRESS=ADDRESS_TO_RECEIVE_THE_TRANSFER

Then create src/example.ts:

src/example.ts
import { setTimeout as sleep } from 'node:timers/promises';
import {
address,
createEmptyClient,
lamports,
type ClusterUrl,
type ClientWithTransactionSending,
type TransactionSigner,
} from '@solana/kit';
import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan';
import { payerFromFile } from '@solana/kit-plugin-payer';
import { rpc, rpcTransactionPlanExecutor } from '@solana/kit-plugin-rpc';
import { getTransferSolInstruction } from '@solana-program/system';
import {
quicknodePriorityFees,
quicknodeTransactionPlanner,
PriorityFeeTier,
type QuicknodePriorityFeesExtension,
} from './index';

const QUICKNODE_ENDPOINT = process.env.QUICKNODE_ENDPOINT!;
const JUPITER_PROGRAM = process.env.JUPITER_PROGRAM!;
const PAYER_KEYPAIR_PATH = process.env.PAYER_KEYPAIR_PATH!;
const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

Standalone Fee Estimation

demonstrateFeeEstimation accepts the shared client from main and calls estimatePriorityFees directly to print the full fee breakdown. The function is typed to require only estimatePriorityFees, so it works with any client that has quicknodePriorityFees attached — including a bare createEmptyClient() if you only need fee lookups without RPC or transaction sending:

src/example.ts
async function demonstrateFeeEstimation(client: {
estimatePriorityFees: QuicknodePriorityFeesExtension['estimatePriorityFees'];
}) {
// Full response — useful when you want to display all tiers to users.
const estimate = await client.estimatePriorityFees({
account: JUPITER_PROGRAM,
last_n_blocks: 150,
});

console.log('Slot:', estimate.context.slot);
console.log('Per-CU fees (micro-lamports):');
console.log(' low :', estimate.per_compute_unit.low);
console.log(' medium :', estimate.per_compute_unit.medium);
console.log(' high :', estimate.per_compute_unit.high);
console.log(' extreme :', estimate.per_compute_unit.extreme);
console.log('Recommended:', estimate.recommended);
}

Transfer with Dynamic Priority Fee

sendTransactionWithPriorityFees takes the shared client, which already has payer attached via payerFromFile, and builds a transfer instruction using client.payer, then calls client.sendTransaction. Because the client was created with quicknodeTransactionPlanner, a fresh priority fee is fetched from the API automatically before the transaction is signed and sent — no manual fee wiring needed at the call site.

src/example.ts
async function sendTransactionWithPriorityFees(
client: ClientWithTransactionSending & { payer: TransactionSigner },
) {
const recipient = address(RECIPIENT_ADDRESS);
const result = await client.sendTransaction(
getTransferSolInstruction({
source: client.payer,
destination: recipient,
amount: lamports(1_000_000n), // 0.001 SOL
}),
);

console.log('Transaction confirmed!');
console.log('Signature:', result.context.signature);
}

Create Client

Now add the client code to tie everything together:

src/example.ts
async function main() {
const client = await createEmptyClient()
.use(rpc(QUICKNODE_ENDPOINT as ClusterUrl))
.use(payerFromFile(PAYER_KEYPAIR_PATH))
.use(quicknodePriorityFees({ url: QUICKNODE_ENDPOINT, defaults: { account: JUPITER_PROGRAM } }))
.use(quicknodeTransactionPlanner({
tier: PriorityFeeTier.Recommended,
onFee: (fee) => console.log('Priority fee used (micro-lamports):', fee),
}))
.use(rpcTransactionPlanExecutor())
.use(planAndSendTransactions());

await demonstrateFeeEstimation(client);
await sleep(1000); // pause to prevent rate limiting
await sendTransactionWithPriorityFees(client);
}

main()

main builds a composable client alongside other Kit plugins. The order matters: quicknodePriorityFees must come before quicknodeTransactionPlanner because the planner calls client.getPriorityFee, which is added by the fees plugin. The onFee callback logs each priority fee as it's used — handy for debugging or analytics. rpcTransactionPlanExecutor is added as a separate step after the planner, keeping planning and execution concerns in distinct plugins.

quicknodeTransactionPlanner uses rpcTransactionPlanner from @solana/kit-plugin-rpc under the hood. If you need direct control, or want to swap in your own custom planner, you can do the same thing inline with an async .use() call:

const client = await createEmptyClient()
.use(rpc(QUICKNODE_ENDPOINT as ClusterUrl))
.use(payerFromFile(PAYER_KEYPAIR_PATH))
.use(quicknodePriorityFees({ url: QUICKNODE_ENDPOINT, defaults: { account: JUPITER_PROGRAM } }))
.use(rpcTransactionPlanExecutor())
.use(async (c) => {
const priorityFees = await c.getPriorityFee(PriorityFeeTier.Recommended);
return rpcTransactionPlanner({ priorityFees, payer: c.payer })(c);
})
.use(planAndSendTransactions());

This gives you the same automatic fee injection as quicknodeTransactionPlanner but with direct access to rpcTransactionPlanner if you want to pass additional options or swap in a different planner.

Run Example

tsx --env-file=.env src/example.ts

Expected output:

Slot: 402493495
Per-CU fees (micro-lamports):
low : 15000
medium : 101760
high : 612373
extreme : 1826194
Recommended: 1108408

Transaction confirmed!
Signature: 3iEZx...WsbC

Which Fee Tier Should I Use?

For most use cases, recommended is a safe default that reflects Quicknode's own suggested value based on recent network activity. Use high for time-sensitive transactions like DEX trades or NFT mints, and extreme only when the network is heavily congested and inclusion speed is critical.

TierWhen to use
lowNon-time-sensitive operations (e.g., off-peak metadata updates)
mediumStandard dApp interactions
highCompetitive transactions (DEX trades, NFT mints)
extremeTime-critical operations under heavy load
recommendedGeneral-purpose default; Quicknode's own suggested value

Scope the estimate to the program you are interacting with via the account param. A fee estimate scoped to the Jupiter program (as in this example) will be more accurate for a Jupiter swap than a network-wide estimate.

Publish the Plugin

Because a Kit plugin is just a standard ESM package with no Solana-specific runtime dependencies beyond a peer dependency on @solana/kit, you can publish it to npm and other developers can install it and drop it into their own .use() chain like any other dependency.

Wrapping Up

In this guide you built a self-contained Solana Kit plugin that integrates Quicknode's Priority Fee API directly into the client chain. Along the way you saw how Kit's curried plugin pattern keeps custom functionality composable and fully typed, how to expose both a full estimate and a single-value convenience method, and how to wire a dynamically fetched fee into the transaction planner so it's applied automatically at send time.

From here you could adapt the same plugin pattern to wrap other Quicknode add-ons — token metadata, DAS API calls, or custom RPC methods — and compose them together in a single .use() chain. You could also publish the plugin as a standalone npm package so other developers can use it in their projects.

Resources


We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide