Fixing IndexedDB Initialization Errors with TanStack Router + Effect + Pglite
Keep IndexedDB and Pglite out of SSR with TanStack Router's ClientOnly, avoiding the classic null database instance error.
This post contains affiliate links for tools I use in production. If you buy through them I earn a commission at no extra cost to you. Recommendations are based on my own experience.
Table of Contents
Overview
This covers a migration from fp-ts to Effect, where the app needed local-first database migrations using Pglite. Pglite can run against IndexedDB, an in-memory store, or the filesystem, but the IndexedDB backend only exists in a browser: it fails silently, or not so silently, the moment SSR tries to touch it.
Combining TanStack Router and Pglite in a server-rendered React app surfaces exactly that problem: the server-side render path tries to initialize IndexedDB and throws:
TypeError: Cannot read properties of null (reading 'open') at Object.getDB (...) ...Calling into pglite before the client has mounted causes this. Below is the cause and the fix.
Background
The Tech Stack
- Effect: improves on fp-ts’s concepts for side effects, concurrency, and error handling.
- TanStack Router: a router for React with optional server-side rendering (SSR).
- Pglite: a client-side Postgres build that can persist to IndexedDB.
- Local-first migrations: following approaches like this guide, migrations run in the browser against the local database.
The Cause
Because TanStack Router supports SSR, the root component’s code executes on the server too. That code called into Pglite’s initialization, which depends on IndexedDB, and there is no window or IndexedDB in a Node SSR process. pglite expects to call open() on a real db instance; on the server that instance is null, so the call throws.
The Solution
TL;DR: keep all Pglite and IndexedDB code inside a client-only boundary.
Here is how
1. Remove initialization from the root route
Per TanStack Router’s SSR guide, any code in your root route runs on both server and client during SSR. Keep Pglite initialization out of it entirely.
2. Wrap Pglite in TanStack Router’s ClientOnly
Older versions of this pattern hand-rolled an isMounted state and a useEffect to defer rendering until the client mounted. TanStack Router now ships a ClientOnly component (and a useHydrated hook) that does exactly this, so there is no need to write it yourself:
import { useEffect, useState } from "react";import { ClientOnly } from "@tanstack/react-router";import { PGliteProvider } from "@electric-sql/pglite-react";import { PgliteDrizzleContext } from "@/hooks/use-pglite-drizzle";import { RuntimeClient } from "@/db/runtime-client";import { Effect, DateTime } from "effect";import { Pglite } from "@/db/services/pglite";import { Migrations } from "@/db/services/migrations";import { ReadApi } from "@/db/services/read-api";import { SeedApi } from "@/db/services/seed-api";import { WriteApi } from "@/db/services/write-api";
function PgliteBoundary({ children }: { children: React.ReactNode }) { const [client, setClient] = useState<any>(null); const [orm, setOrm] = useState<any>(null);
useEffect(() => { async function initPglite() { // Initialize pglite on the client const pgliteInstance = RuntimeClient.runPromise( Effect.gen(function* () { const pglite = yield* Pglite; return pglite; }) );
// Run migrations on the client side RuntimeClient.runPromiseExit( Effect.gen(function* () { console.log("Migrating database..."); const migrations = yield* Migrations; const readApi = yield* ReadApi; const writeApi = yield* WriteApi; const seedApi = yield* SeedApi;
const latestMigration = migrations.length; console.log("Latest migration:", latestMigration);
const { version } = yield* readApi.getSystem.pipe( Effect.catchTags({ PgliteError: () => Effect.succeed({ version: 0 }), // No db yet }) );
console.log("Current version:", version);
// Apply all unrun migrations yield* Effect.all(migrations.slice(version));
// If no system record, create one and seed data if (version === 0) { yield* writeApi.createSystem; yield* seedApi.seedTodos; }
// Update system version yield* writeApi.updateSystemVersion(latestMigration); yield* Effect.log( version === latestMigration ? "Database up to date" : `Migrations done (from ${version} to ${latestMigration})` );
return yield* DateTime.now; }).pipe(Effect.tapErrorCause(Effect.logError)) );
setClient((await pgliteInstance).client); setOrm((await pgliteInstance).orm); } initPglite(); }, []);
if (!client) { return null; }
return ( <PGliteProvider db={client}> <PgliteDrizzleContext.Provider value={orm}> {children} </PgliteDrizzleContext.Provider> </PGliteProvider> );}
export function ClientOnlyPgliteProvider({ children }: { children: React.ReactNode }) { return ( <ClientOnly fallback={null}> <PgliteBoundary>{children}</PgliteBoundary> </ClientOnly> );}ClientOnly guarantees the wrapped tree never runs during SSR, which removes an entire class of “works locally, breaks on the server” bugs. The inner useEffect still guards the async Pglite setup itself, since mounting on the client and having an initialized database are two different moments.
3. Use the provider in a client-rendered route
// In a new route or existing route where you need the client DBimport { ClientOnlyPgliteProvider } from "@/context/ClientOnlyPgliteProvider";import { createFileRoute } from "@tanstack/react-router";import { IndexComponent } from "./IndexComponent"; // example component
export const Route = createFileRoute("/test_indexdb_page/")({ component: RootDocument,});
function RootDocument() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <ClientOnlyPgliteProvider> <IndexComponent /> </ClientOnlyPgliteProvider> </div> );}With this in place, SSR never attempts to open IndexedDB, so the null database instance error goes away. All database logic, migrations included, runs only in the browser after hydration.
Debugging this in production
This exact error only appears on the server render path, so it will not reproduce in a client-side dev server and won’t show up in browser devtools either. It shows up as a 500 on first load, intermittently, depending on which route rendered first. A session replay and error tracker such as Sentry or LogRocket catches server-side exceptions like this with the request context attached, and both have a free tier that covers a side project.
Conclusion
Switching from fp-ts to Effect gives a cleaner, more structured way to handle side effects and concurrency. But any client-only library that depends on IndexedDB, Pglite included, has to be isolated from the SSR code path. TanStack Router’s ClientOnly component makes that isolation a one-line wrapper instead of hand-rolled mount-state tracking.
Key takeaways:
- Keep client-specific libraries out of the server render cycle.
- Use
ClientOnly(oruseHydrated) to defer rendering until after hydration, instead of a manualisMountedstate. - Centralize client-only logic in a dedicated provider to keep the rest of the app clean.
If you are taking this into production, the next steps below cover auth, data and observability, and the newsletter is where the Astro SaaS boilerplate ships first.
Next steps: scaling to production
If you take this into production, these are the pieces I would add first.
- Clerk Clerk provides drop-in authentication and user management components. Hosted auth saves the login, session and org code you would otherwise maintain.
- Supabase Supabase is a hosted Postgres platform with authentication and storage built in. Postgres with row-level security, so the data layer is ready for multi-tenant apps.
- Sentry Sentry captures errors and performance traces from production applications. Errors and slow transactions from real users, with source maps, before customers report them.
Production-ready Astro + TanStack architecture
Get the architecture cheat sheet and join the waitlist for the Astro SaaS boilerplate.