Next.js Integration

This guide shows you how to add hypequery to an existing Next.js App Router project, expose analytics via API routes, and consume them with type-safe React hooks.

What You’ll Build

A taxi analytics dashboard using NYC taxi trip data. By the end of this guide:

  • ✅ Type-safe ClickHouse queries for trip statistics
  • ✅ HTTP API at /api/analytics/* with automatic OpenAPI
  • ✅ Type-safe React hooks for client components
  • ✅ Server-side execution with api.run() for SSR

Example dataset: NYC taxi trips with pickup/dropoff locations, fares, payment types, and distances.

Prerequisites

  • Existing Next.js 13+ App Router project
  • ClickHouse instance (local or hosted)
  • Node.js 18+
  • Sample data: This guide uses the NYC Taxi dataset - adapt the queries for your own data

Step 1: Install Dependencies

# Core packages
npm install @hypequery/clickhouse @hypequery/serve zod

# React hooks (for client components)
npm install @hypequery/react @tanstack/react-query

# CLI (required for type generation in Step 3)
npm install -D @hypequery/cli

Step 2: Configure Environment Variables

Create or update .env.local:

# .env.local
CLICKHOUSE_HOST=http://localhost:8123
CLICKHOUSE_USERNAME=default
CLICKHOUSE_PASSWORD=your_password
CLICKHOUSE_DATABASE=default

Username env var: We standardize on CLICKHOUSE_USERNAME (the name used in the ClickHouse samples). If your deployment exposes CLICKHOUSE_USER, set both variables or update analytics/client.ts to read the name your host provides.

Shared config: These env vars power everything—Next.js API routes, React hooks, and the npx hypequery CLI. Keep the names exactly as shown so every layer (including hypequery dev) reads the same ClickHouse connection without extra fallbacks.

Important: Add .env.local to .gitignore if not already there.

Step 3: Generate TypeScript Types

Generate types from your ClickHouse schema:

npx hypequery generate

What this does:

  • Connects to your ClickHouse instance
  • Introspects your table schemas
  • Generates analytics/schema.ts with TypeScript types

Requires the CLI from Step 1. If @hypequery/cli isn’t installed, npx hypequery generate will fail.

This file is auto-generated. Don’t edit it manually - regenerate when your schema changes.

Step 4: Create ClickHouse Client

Create analytics/client.ts (you write this manually):

import { createQueryBuilder } from '@hypequery/clickhouse';
import type { IntrospectedSchema } from './schema';

export const db = createQueryBuilder<IntrospectedSchema>({
  host: process.env.CLICKHOUSE_HOST!,
  username: process.env.CLICKHOUSE_USERNAME!,
  password: process.env.CLICKHOUSE_PASSWORD!,
  database: process.env.CLICKHOUSE_DATABASE!,
});

Note: Place this in analytics/ directory at project root (not src/).

Using the NYC taxi dataset? This example assumes you have the NYC taxi trips data loaded in ClickHouse. You can also adapt this to any other dataset by changing the table name and columns.

Step 5: Define Analytics Queries

Create analytics/queries.ts (you write this manually). We set basePath: '/api/analytics' so every registered query lands under the same prefix your Next.js route serves:

import { formatDateTime } from '@hypequery/clickhouse';
import { initServe } from '@hypequery/serve';
import { z } from 'zod';
import { db } from './client';

// Initialize with context factory
const { define, queries, query } = initServe({
  context: () => ({ db }),
});

// Define your analytics catalog
export const api = define({
  basePath: '/api/analytics',
  queries: queries({
    dailyStats: query
      .describe('Daily trip counts and revenue')
      .input(z.object({
        startDate: z.string().datetime(),
        endDate: z.string().datetime(),
      }))
      .output(z.array(z.object({
        day: z.string(),
        trip_count: z.number(),
        total_revenue: z.number(),
        avg_fare: z.number(),
        avg_distance: z.number(),
      })))
      .query(({ ctx, input }) =>
        ctx.db
          .table('trips')
          .select([
            formatDateTime('pickup_datetime', 'Y-MM-dd', { alias: 'day' }),
          ])
          .where('pickup_datetime', 'gte', input.startDate)
          .where('pickup_datetime', 'lte', input.endDate)
          .groupBy(['day'])
          .count('trip_id', 'trip_count')
          .sum('total_amount', 'total_revenue')
          .avg('fare_amount', 'avg_fare')
          .avg('trip_distance', 'avg_distance')
          .orderBy('day', 'ASC')
          .execute()
      ),
  }),
});

// Register HTTP routes (REQUIRED for HTTP access)
api.route('/daily-stats', api.queries.dailyStats, { method: 'POST' });

Key points:

  • Use ctx.db (not db) inside query functions
  • Always call .execute() at the end
  • Return the promise (don’t await inside query function)
  • Register routes with api.route() to expose via HTTP
  • Set basePath once if you want all routes under a common prefix (e.g., /api/analytics).

Step 6: Create Next.js API Route

Create app/api/analytics/[...path]/route.ts (you write this manually):

import { api } from '@/analytics/queries';
import { createFetchHandler } from '@hypequery/serve/adapters/fetch';

// Adapt hypequery's handler to the App Router fetch API
const handler = createFetchHandler(api.handler);

// Force Node.js runtime (not Edge)
export const runtime = 'nodejs';

// Export the unified handler for all HTTP methods
export const GET = handler;
export const POST = handler;
export const OPTIONS = handler;

What this does:

  • Catch-all route handles /api/analytics/*
  • createFetchHandler ensures App Router returns a proper Response for each verb
  • Routes defined with api.route() (prefixed by basePath) are now accessible via HTTP

Path alias setup: If @/analytics/queries doesn’t work, configure in tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Step 7: Set Up React Query Provider

Create app/providers.tsx (you write this manually):

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Wrap your app in app/layout.tsx:

import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

This step is REQUIRED - React Query hooks won’t work without the provider.

Step 8: Create Typed Hooks

Create lib/analytics.ts (you write this manually). Because this file exports client hooks, mark it as a client module and only import the server API for types:

'use client';

import { createHooks } from '@hypequery/react';
import { InferApiType } from '@hypequery/serve';
import type { api } from '@/analytics/queries';

// Automatically extract types from your API definition
type AnalyticsApi = InferApiType<typeof api>;

export const { useQuery, useMutation } = createHooks<AnalyticsApi>({
  baseUrl: '/api/analytics',
  config: {
    dailyStats: { method: 'POST' },
  },
});

What this does:

  • 'use client'; ensures the module can use React Query on the client while import type keeps the api reference type-only.
  • InferApiType extracts types from your query definitions for end-to-end safety.
  • config supplies the HTTP method map that createHooks needs (client modules can’t import the runtime api object, so we declare the methods manually).

Step 9: Use in Client Components

Create app/dashboard/page.tsx:

'use client';

import { useQuery } from '@/lib/analytics';

export default function DashboardPage() {
  const { data: dailyStats, isLoading, error } = useQuery('dailyStats', {
    startDate: '2024-01-01T00:00:00Z',
    endDate: '2024-01-31T23:59:59Z',
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>NYC Taxi Dashboard</h1>

      <section>
        <h2>Daily Statistics</h2>
        {dailyStats?.map((day) => (
          <div key={day.day}>
            <strong>{day.day}</strong>: {day.trip_count.toLocaleString()} trips,
            ${day.total_revenue.toLocaleString()} revenue (avg ${day.avg_fare.toFixed(2)}/trip,
            {day.avg_distance.toFixed(1)} mi)
          </div>
        ))}
      </section>
    </div>
  );
}

Type safety:

  • Query names are autocompleted (try 'dailyStats' and see TS guide you)
  • Input types are enforced (TypeScript error if you pass wrong shape)
  • Output types are inferred (no need to manually type data)

Step 10: Use in Server Components (Optional)

For Server Components or API routes, use api.run() directly:

// app/report/page.tsx
import { api } from '@/analytics/queries';

export default async function ReportPage() {
  // Execute directly on the server
  const stats = await api.run('dailyStats', {
    input: {
      startDate: '2024-01-01T00:00:00Z',
      endDate: '2024-01-31T23:59:59Z',
    },
  });

  const totalTrips = stats.reduce((sum, day) => sum + Number(day.trip_count), 0);
  const totalRevenue = stats.reduce((sum, day) => sum + Number(day.total_revenue), 0);

  return (
    <div>
      <h1>Monthly Report</h1>
      <p>Total Trips: {totalTrips.toLocaleString()}</p>
      <p>Total Revenue: ${totalRevenue.toLocaleString()}</p>
      <h2>Daily Breakdown</h2>
      <pre>{JSON.stringify(stats, null, 2)}</pre>
    </div>
  );
}

Type inference caveat: api.run() executes the query directly against ClickHouse and returns raw client values (numbers often come back as strings). Zod output schemas are enforced in the HTTP handler + React hooks path, but server-side api.run() currently doesn’t narrow those types—cast or coerce as needed (see Number(...) above).

When to use api.run() vs hooks:

  • Server Components: Use api.run()
  • Server Actions: Use api.run()
  • Client Components: Use useQuery hooks
  • API Routes: Use api.run() or return api.handler

Testing Locally

# Start Next.js dev server
npm run dev
# Your API is now at: http://localhost:3000/api/analytics/*

# Optional: Run hypequery dev server for interactive docs
npx hypequery dev analytics/queries.ts
# Docs at: http://localhost:4000/docs

Testing Your API

# Test daily stats endpoint
curl -X POST http://localhost:3000/api/analytics/daily-stats \
  -H "Content-Type: application/json" \
  -d '{"startDate":"2024-01-01T00:00:00Z","endDate":"2024-01-31T23:59:59Z"}'

# View OpenAPI spec
curl http://localhost:3000/api/analytics/openapi.json

Deployment (Vercel)

No special configuration needed! Deploy as usual:

vercel deploy

Project Structure

After following this guide, your structure should look like:

your-nextjs-app/
├── analytics/              # Analytics layer (project root)
│   ├── schema.ts          # Auto-generated by `hypequery generate`
│   ├── client.ts          # You write this
│   └── queries.ts         # You write this
├── app/
│   ├── api/
│   │   └── analytics/
│   │       └── [...path]/
│   │           └── route.ts  # You write this
│   ├── dashboard/
│   │   └── page.tsx       # You write this (client component)
│   ├── providers.tsx      # You write this
│   └── layout.tsx         # You update this
├── lib/
│   └── analytics.ts       # You write this (React hooks)
├── .env.local             # You create this
└── tsconfig.json          # Configure path aliases