Stop blank screens on CSR revisits.A three-layer toolkit that restores the last screen instantly.
FirstTx combines Prepaint, Local-First, and Tx into one toolkit.
Keep your CSR architecture while adding instant revisit recovery, offline resilience, and safe optimistic UI.
Before FirstTx
User revisit
User clicks back or re-enters the CSR app.
Blank screen
1-2 seconds of white screen or spinner while JS and data load.
JS load & mount
React mounts only after bundles and API calls finish.
After FirstTx
Boot snippet
A tiny boot script runs right after the HTML is loaded.
Last screen restore
Restores the last DOM snapshot from IndexedDB instantly on revisit.
Hydration & sync
React hydrates and data sync runs in the background while the user continues.
THREE LAYERS · ONE TOOLKIT
Prepaint · Local-First · Tx
Pick the layers you need. Use Prepaint for revisit speed, and add Local-First and Tx for sync and optimistic UI.
Capture the last screen as a DOM snapshot and restore it before React loads.
- Store full-screen snapshots in IndexedDB
- Restore on revisit before any JS runs
- Make CSR revisits feel close to SSR
Use IndexedDB as the source of truth and automate server sync.
- Type-safe models defined with zod
- TTL and staleness metadata built in
- Background sync for offline-friendly flows
Wrap optimistic UI updates in transactions you can safely roll back.
- Run multi-step updates as a single transaction
- Compensating rollback on failure
- Keep UI state consistent on network errors
HOW IT FEELS
User-facing behavior in your app
SSR makes first visits fast. FirstTx focuses on everything that happens after: revisits, back navigation, and tabbing around, so users see a ready screen instead of a blank one.
Internal tools with frequent revisits
CRM, admin, and dashboard users jump between list and detail all day. Show the previous state instantly instead of reloading each time.
Refreshing in the middle of a task
Local models keep the latest snapshot, so an accidental refresh keeps filters, scroll, and form state instead of starting over.
When optimistic UI fails
Run optimistic updates as transactions. If the server rejects a change, the screen rolls back cleanly instead of getting stuck half-updated.
Handling offline and flaky networks
A simple sync hook turns your data layer into something that tolerates offline and reconnection, without users losing their place.
IndexedDB snapshot → DOM restore (4 ms)
TTL exceeded → background sync starting
UI update and server request succeeded
Network failure → compensate and restore UI state
In the FirstTx DevTools Chrome extension, these events appear as a layered timeline you can filter and inspect.
QUICK START
Wire up FirstTx in three small steps
Install the packages, enable the Vite plugin, and wrap your root. You can start with all three layers or add Local-First and Tx after Prepaint.
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx
# 선택적으로 필요한 레이어만 설치할 수도 있습니다.
pnpm add @firsttx/prepaint
pnpm add @firsttx/prepaint @firsttx/local-first
pnpm add @firsttx/local-first @firsttx/tx1. Enable the Vite plugin
import { defineConfig } from "vite";
import { firstTx } from "@firsttx/prepaint/plugin/vite";
export default defineConfig({
plugins: [firstTx()],
});2. Swap your entry point
import { createFirstTxRoot } from "@firsttx/prepaint";
import App from "./App";
createFirstTxRoot(
document.getElementById("root")!,
<App />
);Local-First model and sync hook
Local-Firstimport { defineModel, useSyncedModel } from "@firsttx/local-first";
import { z } from "zod";
const CartModel = defineModel("cart", {
schema: z.object({
items: z.array(
z.object({
id: z.string(),
name: z.string(),
qty: z.number(),
}),
),
}),
});
function CartPage() {
const { data: cart } = useSyncedModel(
CartModel,
() => fetch("/api/cart").then((r) => r.json()),
);
if (!cart) return <Skeleton />;
return <div>{cart.items.length} items</div>;
}Wrap optimistic updates in a transaction
Update the UI first and sync to the server, with a clear rollback path when requests fail.
import { startTransaction } from "@firsttx/tx";
async function addToCart(item: CartItem) {
const tx = startTransaction();
await tx.run(
() =>
CartModel.patch((draft) => {
draft.items.push(item);
}),
{
compensate: () =>
CartModel.patch((draft) => {
draft.items.pop();
}),
},
);
await tx.run(() =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(item),
}),
);
await tx.commit();
}