Fixing IndexedDB Initialization Errors with TanStack Router + Effect + Pglite

| January 9, 2025

Fixing IndexedDB Initialization Errors with TanStack Router + Effect + Pglite

Table of Contents

Overview

Recently, I am doing a migration from fp-ts to Effect. My application needed to run local-first database migrations using Pglite, which support on IndexedDB, In Memory DB, File system. The problem only happen and must happen if not dealing properly using IndexedDB with SSR framework like Tanstack Start.

However, I ran into a critical issue when combining TanStack Router and Pglite in a server-rendered React app: the server-side code tried to initialize IndexedDB, finally after debugging I find out it is leading to a error like:

TypeError: Cannot read properties of null (reading 'open')
at Object.getDB (...)
...

It turned out that attempting to use pglite before the client was fully loaded caused these errors. Below, I’ll walk through the problem and my solution.


Background

The Tech Stack

  • Effect: A library that improves on concepts from fp-ts, offering a clean way to manage side effects, concurrency, and error handling.
  • TanStack Router: A powerful router for React that can optionally be used for server-side rendering (SSR).
  • Pglite: A client-side database solution leveraging IndexedDB.
  • Local-First Migrations: Following approaches like this guide, I needed to run migrations locally in the browser.

The Cause

Because TanStack Router supports SSR, my root component code was being executed on the server. This code tried to run Pglite’s initialization—which relies on IndexedDB—resulting in an error because there’s no window or IndexedDB available during SSR. Since pglite expects to call open() on a valid db instance, but since SSR is happening in a Node environment, there’s no actual IndexedDB to connect to. As a result, the reference to the database was null or undefined during the server render.


The Solution

TL;DR: Put all the pglite indexdb related code in client only pages!

Here is how:

I resolved the issue by strictly deferring IndexedDB initialization to the client side. Here’s the approach:

  1. Remove Initialization from _root.tsx

  2. Create a Client-Only Pglite Provider

    • Wrap the Pglite logic in a React component that uses useEffect.
    • This ensures the code (migrations, connections, etc.) only runs after the app mounts in the browser.
    ClientOnlyPgliteProvider.tsx
    import { useEffect, useState } from "react";
    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";
    export function ClientOnlyPgliteProvider({ children }: { children: React.ReactNode }) {
    const [isMounted, setIsMounted] = useState(false);
    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);
    setIsMounted(true);
    }
    initPglite();
    }, []);
    if (!isMounted) {
    // Render nothing or a loading indicator
    return null;
    }
    return (
    <PGliteProvider db={client}>
    <PgliteDrizzleContext.Provider value={orm}>
    {children}
    </PgliteDrizzleContext.Provider>
    </PGliteProvider>
    );
    }
  3. Use the Client-Only Provider in a Client-Only Route

    • Wherever you need Pglite, wrap your components in ClientOnlyPgliteProvider.
    • This ensures all IndexedDB and database operations are attempted only after the client mounts.
    // 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>
    );
    }

By using this pattern, SSR won’t attempt to open IndexedDB, preventing the null database instance errors. All database logic, including migrations, happens only in the browser after hydration.


Conclusion

Switching from fp-ts to Effect has offered a cleaner, more structured way to handle side effects and concurrency. However, any client-side library (like Pglite) that depends on IndexedDB must be carefully isolated from SSR code paths, especially in frameworks like TanStack Router.

Key Takeaways:

  1. Defer client-only logic: Keep client-specific libraries out of the server render cycle.
  2. Use useEffect: React’s useEffect defers code until after the component mounts on the client.
  3. Wrap in a dedicated provider: Centralize your client-only logic in a provider to keep the application code clean and maintainable.

This approach ensures a smooth integration of SSR, local-first data strategies, and powerful functional paradigms, allowing you to reap the benefits of server-rendering without sacrificing the convenience of in-browser databases.