StarterApp Docs
Packages

@workspace/convex

Pre-configured Convex React client with null-safe CI pattern

Singleton Convex client that gracefully handles missing configuration in CI environments. Returns null when NEXT_PUBLIC_CONVEX_URL is unset or points to placeholder URLs.

Package Exports

Core Export

convexClient

Pre-configured ConvexReactClient or null when Convex is unavailable.

Client import
import { convexClient } from "@workspace/convex";

// convexClient: ConvexReactClient | null

Initialization Logic

Null-safe initialization
export const convexClient =
  publicEnv.NEXT_PUBLIC_CONVEX_URL &&
  publicEnv.NEXT_PUBLIC_CONVEX_URL !== "https://placeholder.convex.cloud"
    ? new ConvexReactClient(publicEnv.NEXT_PUBLIC_CONVEX_URL)
    : null;

Check if NEXT_PUBLIC_CONVEX_URL is defined

Verify URL is not the placeholder value

If both pass, create ConvexReactClient instance

Otherwise, return null

ConvexClient Type

Type import
import type { ConvexClient } from "@workspace/convex";

// Type of convexClient (ConvexReactClient | null)

Usage Patterns

Provider Setup

Wrap your application when convexClient exists:

app/providers.tsx
import { ConvexProvider } from "convex/react";
import { convexClient } from "@workspace/convex";

export function Providers({ children }: { children: React.ReactNode }) {
  if (!convexClient) {
    // CI mode or missing config - render without Convex
    return <>{children}</>;
  }

  return (
    <ConvexProvider client={convexClient}>
      {children}
    </ConvexProvider>
  );
}

Heads up

Always check for null before using convexClient in provider setup. Null indicates CI environment or missing configuration.

Query Usage

"use client";

import { useQuery } from "convex/react";
import { api } from "@convex/_generated/api";
import { convexClient } from "@workspace/convex";

export function DocumentList() {
  const documents = useQuery(api.documents.list);

  if (!convexClient) {
    return <div>Real-time features unavailable</div>;
  }

  if (documents === undefined) {
    return <div>Loading...</div>;
  }

  return (
    <ul>
      {documents.map(doc => (
        <li key={doc._id}>{doc.title}</li>
      ))}
    </ul>
  );
}

When wrapped in ConvexProvider, components can use hooks directly:

"use client";

import { useQuery } from "convex/react";
import { api } from "@convex/_generated/api";

export function UserProfile() {
  const profile = useQuery(api.users.getCurrentProfile);

  if (profile === undefined) return <div>Loading...</div>;
  if (profile === null) return <div>Not found</div>;

  return <div>{profile.name}</div>;
}

Convex queries return undefined while loading:

const data = useQuery(api.example.get);

// undefined: query loading or Convex unavailable
if (data === undefined) {
  return <Skeleton />;
}

// null: query completed, no data found
if (data === null) {
  return <EmptyState />;
}

// data exists
return <DisplayData data={data} />;

Mutation Usage

components/create-document.tsx
"use client";

import { useMutation } from "convex/react";
import { api } from "@convex/_generated/api";

export function CreateDocument() {
  const create = useMutation(api.documents.create);

  const handleCreate = async () => {
    try {
      const id = await create({ title: "New Document" });
      console.log("Created:", id);
    } catch (error) {
      console.error("Creation failed:", error);
    }
  };

  return <button onClick={handleCreate}>Create Document</button>;
}

Mutation Error Handling

Mutations throw errors when they fail. Always wrap mutation calls in try-catch blocks or use error boundaries.

Architecture

Null Client Pattern

The null-safe pattern solves three problems:

CI Builds Without Backend

Next.js builds succeed in CI without provisioning Convex deployments. Pages render in loading states during build, hydrate with real data in browsers.

Placeholder Detection

Detects placeholder URLs like https://placeholder.convex.cloud and treats them as missing config, preventing connection errors in dev environments.

Graceful Degradation

Applications render without Convex, showing appropriate loading or unavailable states. No crashes from missing WebSocket connections.

Singleton Instance

One shared client instance prevents multiple WebSocket connections:

Singleton pattern
// ✅ Single import, single connection
import { convexClient } from "@workspace/convex";

// ❌ Don't create new clients
const client = new ConvexReactClient(url); // Multiple connections!

Multiple clients cause:

  • Memory leaks from duplicate connections
  • Increased latency from connection overhead
  • Subscription deduplication failures

Environment Detection

Valid Convex URLs must:

  1. Be defined (not undefined or empty string)
  2. Not match placeholder values
  3. Point to actual *.convex.cloud or *.convex.site domains
URL validation
// Valid URLs
"https://happy-animal-123.convex.cloud"
"https://my-deployment.convex.site"

// Invalid URLs (trigger null client)
"https://placeholder.convex.cloud"
""
undefined

CI/CD Integration

Build Behavior

During next build:

NEXT_PUBLIC_CONVEX_URL is read from environment

If missing or placeholder, convexClient is null

Pages render without Convex data (loading states)

Build completes successfully

In browser, client hydrates and establishes connection

Test Configuration

.env.test
# Option 1: Use placeholder (null client)
NEXT_PUBLIC_CONVEX_URL=https://placeholder.convex.cloud

# Option 2: Omit entirely (null client)
# NEXT_PUBLIC_CONVEX_URL=

# Option 3: Use real deployment (requires Convex access)
NEXT_PUBLIC_CONVEX_URL=https://test-deployment.convex.cloud

Mock Convex in tests:

__tests__/example.test.tsx
import { vi } from "vitest";

vi.mock("convex/react", () => ({
  useQuery: vi.fn(() => [{ id: "1", title: "Test" }]),
  useMutation: vi.fn(() => vi.fn()),
  ConvexProvider: ({ children }: any) => children,
}));

Performance Considerations

Connection Management

The singleton pattern optimizes connection usage:

  • One WebSocket serves all subscriptions
  • Automatic reconnection on disconnect
  • Query deduplication across components
  • Efficient subscription batching

Bundle Size

The Convex client adds approximately:

  • convex/react: ~50KB gzipped
  • @convex-dev/better-auth: ~10KB gzipped

Tree-shaking eliminates unused functionality.

Hot Reload

During development, the client maintains connections across hot reloads. Subscriptions persist without reconnection overhead.

Error Handling

Connection Failures

When Convex is configured but unreachable:

Connection error handling
const data = useQuery(api.example.get);

// undefined while connecting or connection failed
if (data === undefined) {
  return <div>Loading...</div>;
}

// Connection restored, data available
return <DisplayData data={data} />;

No Explicit Error State

Convex queries return undefined during connection issues. They don't expose explicit error objects. Use undefined state for loading indicators.

Invalid Configuration

Malformed URLs cause initialization failures:

Console error
[Convex] Failed to connect: Invalid URL

Production builds treat invalid config as missing config, returning null clients without throwing errors.

Security Integration

Authentication Bridge

Convex integrates with BetterAuth through ConvexBetterAuthProvider:

app/providers.tsx
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/client";
import { authClient } from "@workspace/auth/client";
import { convexClient } from "@workspace/convex";

export function Providers({ children }: { children: React.ReactNode }) {
  if (!convexClient) return <>{children}</>;

  return (
    <ConvexBetterAuthProvider client={authClient}>
      <ConvexProvider client={convexClient}>
        {children}
      </ConvexProvider>
    </ConvexBetterAuthProvider>
  );
}

Authentication state synchronizes automatically. Convex queries receive authenticated context.

Query Visibility

All data returned by Convex queries is visible to clients. Security depends on server-side query implementations in convex/ directory, not client-side filtering.

Security pattern
// ❌ Client-side filtering doesn't protect data
const data = useQuery(api.admin.getAllUsers);
const visible = data?.filter(u => u.id === currentUserId);

// ✅ Server-side filtering in Convex function
export const getMyData = query({
  handler: async (ctx) => {
    const user = await ctx.auth.getUser();
    return ctx.db.query("data")
      .filter(q => q.eq(q.field("userId"), user.userId))
      .collect();
  }
});

Debugging

Connection Status

Check connection in browser console:

Connection inspection
import { convexClient } from "@workspace/convex";

if (convexClient) {
  console.log("Convex URL:", convexClient.connectionState);
}

Query Inspection

Debug query state
const data = useQuery(api.example.get, { arg: "value" });

useEffect(() => {
  console.log("Query result:", data);
  console.log("Is loading:", data === undefined);
}, [data]);

React DevTools

The Convex provider appears in React DevTools component tree. Inspect provider props to verify client configuration.

The convex package provides a pre-configured client that handles the complexity of WebSocket management, authentication integration, and CI compatibility. For custom Convex configuration, extend the client setup in app/providers.tsx rather than creating new client instances.