The Infrastructure a Solana dApp Needs: Four Services in Next.js, Four SQLite Files in Rails 8
The backend a real Solana dApp needs — RPC/price caching, durable confirmation tracking, real-time push — as four Next.js services or four SQLite files in Rails 8.
Most Solana dApp tutorials stop at “connect wallet, sign transaction.” Real apps need more, and the more is almost entirely backend plumbing that has nothing to do with the chain:
- A cache, because Solana RPC and the Jupiter price API both rate-limit you, and you can’t hit them on every page load.
- A job queue, because a transaction isn’t done when the wallet popup closes — something has to poll the signature until it finalizes.
- A pub/sub channel, because when a balance changes on-chain you want the UI to update without a refresh.
The interesting part isn’t that you need these three things. It’s how much infrastructure they cost. In a typical Next.js Solana app, each one is a separate service. In a Rails 8 app, all three are SQLite files that ship in the box.
This post compares the two stacks for the exact workloads a Solana dApp puts on them. Everything on the Rails side is running in production today across the seven apps on solrengine.org — WalletTrain, PiggyBank, Mercado, and the rest.
The Next.js shape
Here’s the infrastructure a production Next.js Solana dApp typically provisions. None of it is wrong — it’s the mature, well-trodden path:
| Concern | Common choice |
|---|---|
| App data | Postgres (Supabase / Neon / RDS) |
| RPC + price cache | Redis (Upstash) |
| Background jobs | BullMQ, Inngest, or a separate worker dyno |
| Real-time push | Pusher / Ably, or a standalone WebSocket server |
Four moving parts, usually four bills, four sets of credentials, and four things that can be down independently. Each is individually excellent. The cost is integration: your confirmation worker needs the Redis URL and the Postgres URL and the RPC URL, and your WebSocket relay needs its own deploy target because serverless functions can’t hold a long-lived socket open.
The Rails 8 shape
Rails 8 ships the “Solid” trifecta — Solid Queue, Solid Cache, and Solid Cable — all backed by the database instead of Redis. A SolRengine app runs four SQLite databases and nothing else:
# config/database.yml (production)
production:
primary:
database: storage/production.sqlite3
cache:
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate
No Redis. No worker service. No Pusher account. The whole app — web, jobs, cache, real-time — is four files on one disk, deployed with Kamal to a single box. Let’s walk the three Solana-specific workloads and see how each maps onto one of those files.
Workload 1 — Caching RPC and Jupiter calls (Solid Cache)
Solana RPC providers meter requests, and Jupiter’s free price tier allows one request per second. You cannot fetch a token portfolio from scratch on every render. solrengine-tokens handles this with a two-tier cache:
portfolio = Solrengine::Tokens::Portfolio.new(wallet_address)
portfolio.total_usd_value # SOL + SPL tokens, priced in USD
Underneath:
- Token metadata (name, symbol, decimals, icon) is fetched once from Jupiter and persisted in a
tokenstable in the primary database — it effectively never changes. - USD prices live in
Rails.cache— Solid Cache, the cache database — with a 60-second TTL.
That’s the entire cache story — and the app never writes a line of it. Portfolio is the only API the app code touches; the gem does the caching for you, all through Rails.cache (Solid Cache): it batch-fetches prices from Jupiter, caches each one for 60 seconds (solrengine_tokens/price/<mint>), and caches the assembled portfolio for another 45. A warm dashboard reload makes zero Jupiter calls.
In the Next.js version this same caching is a Redis GETEX/SETEX against a separate Upstash instance with its own connection pool and failure mode. Here it’s a query against a SQLite file already attached to the process. Fewer requests reach Jupiter, and there’s nothing extra to provision.
Where the JS stack wins: if you’re running many app servers, a shared Redis is a genuinely better cache than per-box SQLite — every node sees the same cached price. On a single Kamal box, that advantage is moot. Pick the trade-off that matches your scale.
Workload 2 — Tracking confirmations (Solid Queue)
A Solana transaction is signed and sent from the browser, but it isn’t finalized for a second or more after the wallet popup closes — sometimes much longer at a congested epoch boundary. Something server-side has to watch the signature and update state when it lands. That’s a background job, and background jobs are where “just use a serverless function” falls down: the function returns long before the chain finalizes.
solrengine-transactions ships the job. The app enqueues it and walks away:
class TransfersController < ApplicationController
def create
transfer = current_user.transfers.create!(
transfer_params.merge(status: :submitted)
)
Solrengine::Transactions::ConfirmationJob.perform_later(transfer.id)
redirect_to dashboard_path
end
end
The job polls the signature with a stepped backoff — 2s → 4s → 8s, capped at 30 attempts — advancing the row through submitted → processed → confirmed → finalized (or failed) and broadcasting each step. It runs on Solid Queue, which is just rows in the queue database, worked by a process started from the same Procfile:
web bin/rails server
jobs bin/jobs # Solid Queue worker — no Redis, no BullMQ
The Next.js equivalent is a BullMQ queue on Redis plus a worker dyno, or a managed runner like Inngest. Both work well. Both are another service to deploy, monitor, and pay for — to do something Rails treats as a row in a table.
Workload 3 — Real-time balance updates (Solid Cable)
When a watched account changes on-chain, the dashboard should update itself. solrengine-realtime runs one extra process, bin/solana_monitor, that holds a Solana WebSocket open (serverless functions can’t) and pushes notifications into Solid Cable. The web process picks them up and broadcasts a Turbo Stream:
# config/initializers/solrengine_realtime.rb
Solrengine::Realtime.on_account_change = ->(wallet_address) do
user = User.find_by(wallet_address: wallet_address)
next unless user
Rails.cache.delete_matched("price:*")
portfolio = Solrengine::Tokens::Portfolio.new(wallet_address)
Turbo::StreamsChannel.broadcast_replace_to(
"dashboard_#{user.id}",
target: "portfolio",
partial: "dashboards/portfolio",
locals: { portfolio: portfolio }
)
end
The view subscribes with one Hotwire helper and writes zero lines of client-side WebSocket code:
<%= turbo_stream_from "dashboard_#{current_user.id}" %>
<div id="portfolio"><%= render "portfolio", portfolio: @portfolio %></div>
ActionCable’s transport is Solid Cable — the cable database — so the broadcast crosses from the monitor process to the web process through a SQLite table, not a Redis pub/sub channel or a third-party socket vendor. In Next.js this is where you reach for Pusher, Ably, or a self-hosted socket server, and you write the client subscription by hand.
Where the JS stack wins: a hosted service like Ably will fan out to tens of thousands of concurrent connections across regions far past what one Solid Cable box does. If that’s your scale, pay for it. Most dApps never get there, and the ones that do can graduate one piece without rewriting the app.
Counting it up
Same three Solana workloads, two infrastructure footprints:
| Concern | Next.js (typical) | Rails 8 SolRengine |
|---|---|---|
| App data | Postgres | primary.sqlite3 |
| RPC / price cache | Redis | cache.sqlite3 (Solid Cache) |
| Confirmation jobs | BullMQ + worker | queue.sqlite3 (Solid Queue) |
| Real-time push | Pusher / Ably | cable.sqlite3 (Solid Cable) |
| Services to deploy | 4+ | 1 box, kamal deploy |
The honest summary: the Next.js stack is more horizontally scalable and lets you swap any layer for a best-in-class managed service. The Rails 8 stack is dramatically simpler to run — one repo, one deploy target, one backup to take — and for a Solana dApp serving thousands rather than millions of users, “simpler to run” is usually the constraint that actually matters. You can move a single layer to Redis or Ably later without touching the rest, because the app only ever talked to Rails.cache, perform_later, and broadcast_replace_to.
You don’t choose Rails for Solana because it’s faster than Next.js. You choose it because the day-two operational surface of a dApp — caching a rate-limited price API, durably tracking confirmations, pushing on-chain changes to the browser — is exactly what Rails 8 now does with files instead of services.
Every piece here is open source and live. Clone WalletTrain, run bin/dev, and watch four SQLite files do the work of four services — or start from scratch with the quickstart.