Instant boot script for CSR apps that replays IndexedDB snapshots before React loads, delivering ~0 ms revisit blanks with automatic hydration or clean rerender fallback.
npm install @firsttx/prepaintInstant replay for CSR apps — ~0ms blank screen on revisit
Restores your app's last visual state from IndexedDB before JavaScript loads. No blank screens. Automatic React hydration with graceful fallback.
| ❌ Before prepaint | ✅ After prepaint |
![]() | ![]() |
| Slow 4G: Blank screen exposed | Slow 4G: Instant restore |
``bash`
npm install @firsttx/prepaint


> Module format: ESM-only. CommonJS users should use import() (dynamic import).
---
The only solution that restores UI before JavaScript loads.
- No SSR/SSG infrastructure needed
- Works with any existing CSR React app
- Automatic React hydration with graceful fallback
- Native ViewTransition support
`
Traditional CSR on revisit:
User clicks → Blank screen (2000ms) → Content appears
With Prepaint:
User clicks → Last snapshot (~0ms) → React hydrates → Fresh data
`
Prepaint captures DOM snapshots per route and replays them instantly on the next visit.
---
Prepaint currently provides a Vite plugin only.
`ts
// vite.config.ts
import { firstTx } from '@firsttx/prepaint/plugin/vite';
export default defineConfig({
plugins: [firstTx()],
});
`
`tsx
// main.tsx
import { createFirstTxRoot } from '@firsttx/prepaint';
createFirstTxRoot(document.getElementById('root')!,
`
Done. Prepaint now:
- Captures snapshots on page hide/unload
- Restores them in ~0ms on revisit
- Hydrates with React (or falls back gracefully)
---
``
┌─────────────────────────────────┐
│ 1) Capture (on page leave) │
│ - beforeunload/pagehide/ │
│ visibilitychange │
│ - Saves DOM + styles to │
│ IndexedDB │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 2) Boot (~0ms on revisit) │
│ - Inline script runs │
│ - Reads snapshot from IndexedDB│
│ - Injects into #root │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 3) Handoff (~500ms) │
│ - Main bundle loads │
│ - Hydrates or client-renders │
│ - Cleans up prepaint artifacts │
└─────────────────────────────────┘
Storage
- DB: firsttx-prepaintsnapshots
- Store:
- Key: route pathname
- TTL: 7 days
---
`typescript`
createFirstTxRoot(
container: HTMLElement,
element: ReactElement,
options?: {
transition?: boolean; // ViewTransition (default: true)
onCapture?: (snapshot: Snapshot) => void;
onHandoff?: (strategy: 'has-prepaint' | 'cold-start') => void;
onHydrationError?: (error: Error) => void;
}
);
Behavior
- If snapshot exists + #root has 1 child → hydrateRoot()createRoot()
- Otherwise → fresh render
- On hydration error → fallback to clean render (with ViewTransition)
`typescript`
firstTx({
inline?: boolean, // Inline boot script (default: true)
minify?: boolean, // Minify boot script (default: !isDev)
injectTo?: 'head-prepend' | 'head' | 'body-prepend' | 'body',
nonce?: string | (() => string),
overlay?: boolean, // Enable overlay mode globally
overlayRoutes?: string[], // Overlay for specific routes
})
---
Problem Direct injection into #root can race with routers, causing duplicate DOM.
Solution Overlay mode paints the snapshot above your app in Shadow DOM, then fades out after hydration.
`js
// Option 1: Global flag
window.__FIRSTTX_OVERLAY__ = true;
// Option 2: localStorage (persists)
localStorage.setItem('firsttx:overlay', '1');
// Option 3: Specific routes
localStorage.setItem('firsttx:overlayRoutes', '/prepaint,/dashboard');
// Option 4: Vite plugin
firstTx({ overlay: true });
`
`js`
delete window.__FIRSTTX_OVERLAY__;
localStorage.removeItem('firsttx:overlay');
localStorage.removeItem('firsttx:overlayRoutes');
---
`tsx
import { useModel } from '@firsttx/local-first';
function ProductsPage() {
const [products] = useModel(ProductsModel);
// Prepaint shows last snapshot
// useModel provides instant data from IndexedDB
if (!products) return
return
}
`
`tsx`
// These change on every render → exclude from snapshot
{Date.now()}{Math.random()}
`tsx`
createFirstTxRoot(root,
onCapture: (snapshot) => console.log('Captured:', snapshot.route),
onHandoff: (strategy) => console.log('Strategy:', strategy),
onHydrationError: (err) => console.error('Hydration failed:', err),
});
---
Prepaint only attempts hydration if #root has exactly 1 child. Otherwise → fresh render.
After mount, a MutationObserver watches #root:
- Detects extra children (e.g., router appending siblings)
- Unmounts → clears → re-renders cleanly
- Prevents "double UI" issues
Post-mount:
- Removes style[data-firsttx-prepaint]
- Removes #__firsttx_prepaint__
- Removes overlay host
---
✅ Use ViewTransition (default)
`tsx`
createFirstTxRoot(root,
✅ Mark volatile content
`html`
{timestamp}
✅ Combine with Local-First
`tsx`
const [data] = useModel(Model);
// Instant data from IndexedDB while network refreshes
❌ Don't expect instant availability on first visit
`tsx`
// First visit: snapshot doesn't exist yet
// Only kicks in on second+ visits
❌ Don't capture sensitive data
`tsx`
// Snapshots are plain HTML in IndexedDB
// Avoid capturing auth tokens, PII, etc.
---
Prepaint sanitizes all restored HTML to prevent XSS attacks:
- Removes dangerous tags: