Server-Side RPC, Client-Side Signing: The Split Behind Every SolRengine dApp
Why every SolRengine dApp reads on the server in Ruby and signs in the browser — the server never holds a key, the browser never holds your RPC credentials.
Every Solana dApp has to answer one question before it writes a line of feature code: where does the blockchain talk to your app? Get this boundary wrong and you either leak an RPC key into a JavaScript bundle, or you end up holding users’ private keys on your server. Both are bad in different ways.
SolRengine answers it the same way in all seven deployed apps — WalletTrain, PiggyBank, Mercado, Voting, and the rest. There’s one rule, and it splits cleanly down the middle:
Reads happen on the server in Ruby. Writes get signed in the browser by the user’s wallet.
The server never holds a private key. The browser never holds your RPC credentials. Let’s walk both halves with real code.
Reads: Ruby talks to Solana
Balances, token holdings, transaction history, account data — all of it is a read, and reads go through solrengine-rpc on the server:
class DashboardController < ApplicationController
before_action :authenticate!
def show
client = Solrengine::Rpc.client
@balance = client.get_balance(current_user.wallet_address) # → Float (SOL)
@tokens = client.get_token_accounts(current_user.wallet_address) # → [{mint:, ui_amount:, decimals:}]
@recent = client.get_recent_signatures(current_user.wallet_address)
end
end
That’s it. Solrengine::Rpc.client reads its endpoint from SOLANA_RPC_URL (or config/solana.yml) — server-side environment, never shipped anywhere. If you’re paying for a Helius or QuickNode endpoint with an API key in the URL, that key stays on the box. The browser asks Rails for data; Rails asks Solana.
Because the read path is just Ruby, every Rails tool you already use still works. Cache the response in Solid Cache so you’re not hammering the RPC node on every page load:
@balance = Rails.cache.fetch("balance/#{current_user.wallet_address}", expires_in: 30.seconds) do
Solrengine::Rpc.client.get_balance(current_user.wallet_address)
end
That one line is the whole reason the “read on the server” rule pays off. Your RPC node sees one request per wallet per 30 seconds instead of one per browser tab per page view. Jupiter price lookups in solrengine-tokens lean on the same trick (60-second cache, single-threaded to respect the free tier). The blockchain becomes just another slow upstream you put a cache in front of — exactly the problem Rails has solved for twenty years.
Writes: the browser signs, the server never can
Reads are safe to do server-side because they need no secret. Writes are the opposite — they need a signature from a private key, and the only place that key should ever live is the user’s wallet extension. So signing happens in the browser. The transaction is built with @solana/kit, then handed to the user’s wallet to sign and send over Wallet Standard — the two do different jobs: @solana/kit constructs the transaction, Wallet Standard is the interface Phantom/Solflare/Backpack expose for signing it. Both are wired up by a Stimulus controller from @solrengine/wallet-utils:
<%# app/views/transfers/new.html.erb %>
<form data-controller="transfer"
data-transfer-recipient-value="<%= @recipient %>"
data-transfer-lamports-value="<%= @lamports %>">
<button data-action="transfer#send">Send SOL</button>
</form>
// The controller builds the transaction, then hands it to the wallet to sign + send.
// The private key never leaves the extension; our code never sees it.
export default class extends Controller {
static values = { recipient: String, lamports: Number }
async send() {
const wallet = await connectWallet() // Wallet Standard discovery (@solrengine/wallet-utils)
const tx = buildTransferTransaction({
from: wallet.address,
to: this.recipientValue,
lamports: this.lamportsValue,
})
// signAndSendTransaction is a Wallet Standard feature — Phantom/Solflare/Backpack/Jupiter
const { signature } = await wallet.signAndSendTransaction(tx)
// Hand the signature back to Rails so the server can track confirmation.
await post("/transfers", { signature })
}
}
Notice what the server received: a transaction signature, not a key, not a signed blob it had to custody. The user authorized the transfer in their own wallet UI. Our Rails app couldn’t move their funds if it wanted to — it has no key to do it with.
The handoff back: confirmation is a read again
A signature isn’t a confirmed transaction. The wallet returns the moment it broadcasts; the network still has to confirm it. That’s a read — so it comes back to the server, where a Solid Queue job polls until it’s final:
# The mechanic, simplified — solrengine-transactions ships this as
# Solrengine::Transactions::ConfirmationJob (queue: solana_confirmation).
class ConfirmTransferJob < ApplicationJob
def perform(transfer)
status = Solrengine::Rpc.client.get_signature_status(transfer.signature)
if status["confirmationStatus"] == "finalized"
transfer.update!(status: :confirmed)
Turbo::StreamsChannel.broadcast_replace_to transfer, target: transfer
else
ConfirmTransferJob.set(wait: 3.seconds).perform_later(transfer)
end
end
end
The full lifecycle, then, is a clean loop: server renders → browser signs → server confirms → Turbo Stream updates the UI. Each step lives where it belongs. In practice you don’t write this loop at all — solrengine-transactions ships it as Solrengine::Transactions::ConfirmationJob; you just ConfirmationJob.perform_later(transfer.id) once the signature comes back, and the gem handles the polling and the Turbo broadcast.
Where Next.js makes you choose
None of this is impossible in Next.js — but the framework doesn’t draw the boundary for you, so you have to. The RPC endpoint is the tell. You’ll see it in tutorials as:
const connection = new Connection(process.env.NEXT_PUBLIC_RPC_URL)
NEXT_PUBLIC_ means inlined into the client bundle. Your paid RPC key is now in every visitor’s browser. The fix is to build an API route that proxies reads, keep the key server-side, and call your own route from the client — which is to say, you rebuild the server/client split by hand, route by route. Rails apps get that boundary for free because the server is where Ruby runs; there’s no PUBLIC_ escape hatch to leak through by accident.
The signing half is genuinely the same in both stacks — Wallet Standard runs in any browser, framework or not. SolRengine ships the Stimulus glue (@solrengine/wallet-utils); Next.js apps reach for @solana/wallet-adapter and a tree of React context providers. Different ergonomics, same security model. Credit where it’s due: the wallet-adapter ecosystem is mature and well-documented, and if your team already lives in React, that familiarity is worth something.
The one rule, restated
When you’re deciding where a piece of Solana logic goes, ask whether it needs a secret:
| Operation | Needs a private key? | Where it runs |
|---|---|---|
| Read balance / tokens / history | No | Server (solrengine-rpc, cached) |
| Read account / program state | No | Server |
| Confirm a signature | No | Server (Solid Queue) |
| Sign & send a transaction | Yes | Browser (Wallet Standard) |
Reads are secret-free, so they go where the cache, the database, and your RPC key already live — the server. Signing needs the key, so it stays in the one place that should ever hold it — the user’s wallet. The server holds no keys; the browser holds no credentials. That’s the split behind every SolRengine app, and it’s the first decision worth getting right in any Solana dApp, Rails or not.
Reads run through solrengine-rpc; confirmation tracking lives in solrengine-transactions; wallet signing is wired by @solrengine/wallet-utils. All three are published and running in the deployed showcase apps at solrengine.org.
Want the split working in your own app? Start with the quickstart — bundle add solrengine, run the generator, and you have a wallet-authed dApp with server-side reads and client-side signing in about ten minutes.