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.tswith 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(notdb) 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
basePathonce 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/* createFetchHandlerensures App Router returns a properResponsefor each verb- Routes defined with
api.route()(prefixed bybasePath) 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 whileimport typekeeps theapireference type-only.InferApiTypeextracts types from your query definitions for end-to-end safety.configsupplies the HTTP method map thatcreateHooksneeds (client modules can’t import the runtimeapiobject, 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
useQueryhooks - API Routes: Use
api.run()or returnapi.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
Related
- React Hooks Reference - Complete React hooks API
- Serve API Reference - Full serve framework API
- Deployment Guide - Production deployment patterns