Skip to main content
Version: 0.3.1

Next.js Getting Started Guide

Complete guide to integrating OData APIs with Next.js using the OpenRouter solution.

Overview

The OpenRouter provides a framework-agnostic solution perfect for Next.js applications. It gives you:

  • ✅ Full control over route registration and paths
  • ✅ Compatibility with both App Router and Pages Router
  • ✅ Direct model querying without controllers
  • ✅ Flexible middleware integration
  • ✅ Support for custom authentication and authorization

Prerequisites

  • Node.js 16+ installed
  • Next.js 13+ project (App Router or Pages Router)
  • Basic knowledge of Next.js API routes
  • Database server running (PostgreSQL, MySQL, SQLite, etc.)

Installation

1. Install the package

npm install @phrasecode/odata

2. Install your database driver

Choose one based on your database:

# PostgreSQL
npm install pg pg-hstore

# MySQL
npm install mysql2

# SQLite
npm install sqlite3

See the Installation Guide for details.

Step-by-Step Setup

Step 1: Define Your Models

Create your data models using decorators.

lib/models/user.ts:

import { Model, Table, Column, DataTypes, HasMany } from '@phrasecode/odata';
import type { Order } from './order';

@Table({ tableName: 'users' })
export class User extends Model<User> {
@Column({
dataType: DataTypes.INTEGER,
isPrimaryKey: true,
isAutoIncrement: true,
})
id: number;

@Column({ dataType: DataTypes.STRING, isNullable: false })
name: string;

@Column({ dataType: DataTypes.STRING, isUnique: true })
email: string;

@Column({ dataType: DataTypes.INTEGER })
age: number;

@HasMany(() => require('./order').Order, {
relation: [{ foreignKey: 'userId', sourceKey: 'id' }],
})
orders: Order[];
}

lib/models/order.ts:

import { Model, Table, Column, DataTypes, BelongsTo } from '@phrasecode/odata';
import type { User } from './user';

@Table({ tableName: 'orders' })
export class Order extends Model<Order> {
@Column({
dataType: DataTypes.INTEGER,
isPrimaryKey: true,
isAutoIncrement: true,
})
id: number;

@Column({ dataType: DataTypes.INTEGER })
userId: number;

@Column({ dataType: DataTypes.DECIMAL })
total: number;

@BelongsTo(() => require('./user').User, {
relation: [{ foreignKey: 'id', sourceKey: 'userId' }],
})
user: User;
}

Step 2: Create DataSource Singleton

IMPORTANT: In Next.js, create a singleton DataSource to avoid multiple database connections.

lib/datasource.ts:

import { DataSource } from '@phrasecode/odata';
import { User } from './models/user';
import { Order } from './models/order';

let dataSource: DataSource | null = null;

export function getDataSource(): DataSource {
if (!dataSource) {
dataSource = new DataSource({
dialect: 'postgres',
database: process.env.DB_NAME!,
username: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
host: process.env.DB_HOST!,
port: parseInt(process.env.DB_PORT || '5432'),

// Connection pooling is CRITICAL for Next.js
pool: {
max: 10,
min: 2,
idle: 10000,
acquire: 30000,
},

schema: 'public',
ssl: process.env.NODE_ENV === 'production',

models: [User, Order],
});
}

return dataSource;
}

Step 3: Create OpenRouter Instance

The OpenRouter requires a pathMapping configuration that maps URL paths to models.

lib/odata-router.ts:

import { OpenRouter } from '@phrasecode/odata';
import { getDataSource } from './datasource';
import { User } from './models/user';
import { Order } from './models/order';

let router: OpenRouter | null = null;

export function getODataRouter(): OpenRouter {
if (!router) {
router = new OpenRouter({
dataSource: getDataSource(),
// Map URL paths to models
pathMapping: {
'/api/odata/user': User,
'/api/odata/order': Order,
},
logger: {
enabled: true,
logLevel: process.env.NODE_ENV === 'production' ? 'ERROR' : 'INFO',
format: 'JSON',
},
});
}

return router;
}

Understanding pathMapping

The pathMapping is a key configuration that tells OpenRouter which model to use for each URL path. When a request comes in, the router:

  1. Extracts the path from the incoming URL (strips query parameters)
  2. Matches the path against the pathMapping keys
  3. Identifies the model to use for query execution and response mapping
pathMapping: {
'/api/odata/user': User, // Requests to /api/odata/user → User model
'/api/odata/order': Order, // Requests to /api/odata/order → Order model
}

How it works:

Incoming RequestMatched PathModel Used
/api/odata/user/api/odata/userUser
/api/odata/user?$select=name/api/odata/userUser
/api/odata/user?$filter=age gt 18&$top=10/api/odata/userUser
/api/odata/order?$expand=user/api/odata/orderOrder

Important: The path in pathMapping must match your API route structure exactly. Query parameters are automatically stripped during matching.

App Router Implementation (Next.js 13+)

Step 4A: Create API Routes (App Router)

User Endpoint

app/api/odata/user/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { getODataRouter } from '@/lib/odata-router';

export async function GET(request: NextRequest) {
try {
const router = getODataRouter();
const path = request.url; // /api/odata/user?$select=name&$filter=age gt 18

// Use queryable() to execute OData query (path is matched via pathMapping)
const result = await router.queryable(path);

return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: error.statusCode || 500 });
}
}

Order Endpoint

app/api/odata/order/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { getODataRouter } from '@/lib/odata-router';

export async function GET(request: NextRequest) {
try {
const router = getODataRouter();
const path = request.url; // /api/odata/order?$expand=user

const result = await router.queryable(path);

return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: error.statusCode || 500 });
}
}

Metadata Endpoint

app/api/odata/$metadata/route.ts:

import { NextResponse } from 'next/server';
import { getDataSource } from '@/lib/datasource';

export async function GET() {
try {
const dataSource = getDataSource();
const metadata = dataSource.getMetadata();

return new NextResponse.json(metadata);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

Pages Router Implementation (Next.js 12)

Step 4B: Create API Routes (Pages Router)

User Endpoint

pages/api/odata/user.ts:

import type { NextApiRequest, NextApiResponse } from 'next';
import { getODataRouter } from '@/lib/odata-router';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}

try {
const router = getODataRouter();
const path = req.url;

// Path is matched via pathMapping configuration
const result = await router.queryable(path);

res.status(200).json(result);
} catch (error: any) {
res.status(error.statusCode || 500).json({ error: error.message });
}
}

Metadata Endpoint

pages/api/odata/$metadata.ts:

import type { NextApiRequest, NextApiResponse } from 'next';
import { getDataSource } from '@/lib/datasource';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}

try {
const dataSource = getDataSource();
const metadata = dataSource.getMetadata();

res.setHeader('Content-Type', 'application/xml');
res.status(200).send(metadata);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
}

Environment Variables

Create a .env.local file:

# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
DB_USER=user
DB_PASSWORD=password

# Environment
NODE_ENV=development

Example Queries

Once your API routes are set up, you can query them:

# Get all users
curl http://localhost:3000/api/odata/user

# Select specific fields
curl http://localhost:3000/api/odata/user?$select=name,email

# Filter by condition
curl http://localhost:3000/api/odata/user?$filter=age gt 18

# Expand relationships
curl http://localhost:3000/api/odata/user?$expand=orders

# Metadata
curl http://localhost:3000/api/odata/$metadata

Adding Authentication

Middleware Approach (App Router)

middleware.ts:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
// Only apply to OData routes
if (request.nextUrl.pathname.startsWith('/api/odata')) {
const token = request.headers.get('authorization');

if (!token || !isValidToken(token)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}

return NextResponse.next();
}

function isValidToken(token: string): boolean {
// Implement your token validation logic
return token === 'Bearer valid-token';
}

export const config = {
matcher: '/api/odata/:path*',
};

Per-Route Authentication

app/api/odata/user/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { getODataRouter } from '@/lib/odata-router';
import { User } from '@/lib/models/user';

async function authenticate(request: NextRequest): Promise<boolean> {
const token = request.headers.get('authorization');
// Implement your authentication logic
return token === 'Bearer valid-token';
}

export async function GET(request: NextRequest) {
// Check authentication
if (!(await authenticate(request))) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

try {
const router = getODataRouter();
const queryString = request.nextUrl.searchParams.toString();
const handler = router.queryable(User);
const result = await handler(queryString);

return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: error.statusCode || 500 });
}
}

Custom Query Logic

Add custom logic before executing queries:

export async function GET(request: NextRequest) {
try {
const router = getODataRouter();
let queryString = request.nextUrl.searchParams.toString();

// Example: Force a filter for active users only
const params = new URLSearchParams(queryString);
const existingFilter = params.get('$filter');

if (existingFilter) {
params.set('$filter', `(${existingFilter}) and status eq 'active'`);
} else {
params.set('$filter', "status eq 'active'");
}

// Example: Limit maximum results
const top = params.get('$top');
if (!top || parseInt(top) > 100) {
params.set('$top', '100');
}

queryString = params.toString();

const result = await router.queryable(request.url);

return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: error.statusCode || 500 });
}
}

Raw SQL Queries

For complex queries that OData can't express, use rawQueryable() with QueryModel:

// app/api/reports/top-users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getODataRouter } from '@/lib/odata-router';

export async function GET(request: NextRequest) {
try {
const router = getODataRouter();
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '10');

const result = await router.rawQueryable(
'/api/odata/user', // Path must match a pathMapping.
`SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id
ORDER BY order_count DESC
LIMIT $limit`,
{ limit },
);

return NextResponse.json(result);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

📖 Learn more about Custom Queries