Fixing IndexedDB Initialization Errors with TanStack Router + Effect + Pglite

| January 9, 2025

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:

ClientOnlyPgliteProvider.tsx
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 DB
import { 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:

  1. Keep client-specific libraries out of the server render cycle.
  2. Use ClientOnly (or useHydrated) to defer rendering until after hydration, instead of a manual isMounted state.
  3. 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.