Scheduled API calls are a recurring need in most development projects: syncing data on a timer, refreshing tokens, polling an external service, clearing caches. The typical solution is a cron job on a VPS or a workflow in a third-party automation tool, both of which work until they do not. Cron jobs on remote servers are easy to lose track of, and third-party tools add cost and opacity for what is fundamentally a simple requirement.
This guide builds a self-hosted API scheduler: a Next.js application with a Prisma-backed database, a
node-cronworker that executes registered endpoints on schedule, structured logging for every execution, and NextAuth.js for authentication. The result is a dashboard where you can add an endpoint, set a cron expression, and see every call and its outcome.What this covers:
Project setup and dependencies
Prisma models for endpoints and logs
The scheduler worker with error handling and logging
NextAuth.js credentials authentication
Dashboard routes for managing and monitoring endpoints
Deployment considerations
Why Build Your Own
Third-party automation tools like Zapier and n8n are well-suited for multi-step workflows with many integrations. For the simpler case of calling a set of API endpoints on a schedule, they introduce unnecessary overhead: monthly costs for running a handful of background jobs, limited visibility into what happened when a call fails, and no control over retry logic or timeout behavior.
A custom scheduler built on a standard Next.js stack gives direct control over all of these and runs on any server or serverless platform where Node.js is available.
Step 1: Project Setup
Create the project and install dependencies:
npx create-next-app@latest api-scheduler --typescript
cd api-scheduler
npm install axios node-cron @prisma/client next-auth bcryptjs
npm install --save-dev prisma @types/node-cron @types/bcryptjs
Initialize Prisma:
npx prisma init
Create the environment file:
DATABASE_URL="postgresql://user:password@localhost:5432/api_scheduler"
NEXTAUTH_SECRET="replace-with-a-long-random-string"
NEXTAUTH_URL="http://localhost:3000"
Generate the NEXTAUTH_SECRET with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Step 2: Define the Database Models
Update prisma/schema.prisma with the models for endpoints, logs, and users:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Endpoint {
id String @id @default(cuid())
name String
url String
method String @default("GET")
headers Json?
body Json?
schedule String // cron expression, e.g. "0 */6 * * *"
enabled Boolean @default(true)
lastRun DateTime?
createdAt DateTime @default(now())
logs Log[]
}
model Log {
id String @id @default(cuid())
endpointId String
endpoint Endpoint @relation(fields: [endpointId], references: [id])
status Int
duration Int // milliseconds
error String?
createdAt DateTime @default(now())
}
model User {
id String @id @default(cuid())
email String @unique
password String
}
Run the migration:
npx prisma migrate dev --name init
Step 3: The Scheduler Worker
Create src/lib/scheduler.ts. This worker loads all enabled endpoints, registers a cron job for each, and logs every execution including failures:
import cron from 'node-cron';
import axios from 'axios';
import { db } from './db';
const activeJobs = new Map<string, cron.ScheduledTask>();
export async function startScheduler(): Promise<void> {
const endpoints = await db.endpoint.findMany({
where: { enabled: true },
});
for (const endpoint of endpoints) {
if (!cron.validate(endpoint.schedule)) {
console.warn(`Invalid cron expression for endpoint ${endpoint.id}: ${endpoint.schedule}`);
continue;
}
const task = cron.schedule(endpoint.schedule, async () => {
await runEndpoint(endpoint.id);
});
activeJobs.set(endpoint.id, task);
}
console.log(`Scheduler started with ${endpoints.length} active endpoints`);
}
export async function runEndpoint(endpointId: string): Promise<void> {
const endpoint = await db.endpoint.findUnique({ where: { id: endpointId } });
if (!endpoint) return;
const start = Date.now();
try {
const res = await axios({
method: endpoint.method as string,
url: endpoint.url,
headers: (endpoint.headers as Record<string, string>) ?? {},
data: endpoint.body ?? undefined,
timeout: 30_000,
});
await db.log.create({
data: {
endpointId: endpoint.id,
status: res.status,
duration: Date.now() - start,
},
});
await db.endpoint.update({
where: { id: endpoint.id },
data: { lastRun: new Date() },
});
} catch (err: unknown) {
const status =
axios.isAxiosError(err) ? (err.response?.status ?? 500) : 500;
const message =
err instanceof Error ? err.message : 'Unknown error';
await db.log.create({
data: {
endpointId: endpoint.id,
status,
error: message,
duration: Date.now() - start,
},
});
}
}
export function stopJob(endpointId: string): void {
const job = activeJobs.get(endpointId);
if (job) {
job.stop();
activeJobs.delete(endpointId);
}
}
A few implementation details worth noting:
cron.validate() checks the cron expression before registering a job, which prevents the scheduler from crashing on a malformed schedule.
The error handler uses axios.isAxiosError() for type-safe error narrowing rather than casting to any. The logged status code comes from the actual HTTP response when one was received, defaulting to 500 when the request did not reach the server at all (network error, timeout).
The lastRun timestamp is updated on success so the dashboard can show when each endpoint last ran without querying the logs table.
Step 4: Database Client Singleton
Create src/lib/db.ts to prevent multiple Prisma client instances in development (which causes connection pool exhaustion with Next.js hot reloading):
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({ log: ['error'] });
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
Step 5: Authentication with NextAuth.js
Create src/app/api/auth/[...nextauth]/route.ts:
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
const handler = NextAuth({
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials.password) return null;
const user = await db.user.findUnique({
where: { email: credentials.email },
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null;
return { id: user.id, email: user.email };
},
}),
],
session: { strategy: 'jwt' },
secret: process.env.NEXTAUTH_SECRET,
});
export { handler as GET, handler as POST };
Seed an initial user:
// scripts/seed.ts
import bcrypt from 'bcryptjs';
import { db } from '../src/lib/db';
async function main() {
const hashed = await bcrypt.hash('yourpassword', 12);
await db.user.create({
data: { email: '[email protected]', password: hashed },
});
}
main();
Step 6: API Routes for Endpoints
Create src/app/api/endpoints/route.ts for listing and creating endpoints:
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../auth/[...nextauth]/route';
import { db } from '@/lib/db';
import { runEndpoint } from '@/lib/scheduler';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
const endpoints = await db.endpoint.findMany({
orderBy: { createdAt: 'desc' },
include: { logs: { take: 1, orderBy: { createdAt: 'desc' } } },
});
return NextResponse.json(endpoints);
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
const body = await req.json();
const endpoint = await db.endpoint.create({
data: {
name: body.name,
url: body.url,
method: body.method ?? 'GET',
headers: body.headers ?? null,
body: body.body ?? null,
schedule: body.schedule,
},
});
return NextResponse.json(endpoint, { status: 201 });
}
Add a manual trigger endpoint at src/app/api/endpoints/[id]/trigger/route.ts:
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../../../auth/[...nextauth]/route';
import { runEndpoint } from '@/lib/scheduler';
export async function POST(
_req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
await runEndpoint(params.id);
return NextResponse.json({ triggered: true });
}
Step 7: Starting the Scheduler
In a Next.js App Router project, the scheduler needs to start when the server initializes. Create a custom server or use a startup route. For a simple approach in src/app/api/scheduler/start/route.ts called on server startup:
import { startScheduler } from '@/lib/scheduler';
let started = false;
export async function GET() {
if (!started) {
await startScheduler();
started = true;
}
return new Response('Scheduler running', { status: 200 });
}
For production, a more robust approach is to use a dedicated background process (PM2 worker, a separate Node.js process) that runs the scheduler independently of the web server.
Deployment
The application deploys to any platform that supports Node.js and a PostgreSQL database.
For Vercel: the API routes and dashboard deploy normally, but the cron scheduler cannot run as a persistent process on Vercel's serverless functions. Use Vercel Cron Jobs to call a trigger endpoint on schedule, which runs each registered endpoint.
For a VPS or Fly.io: run the Next.js server with PM2 and let the scheduler run as a persistent background process within the same Node.js instance or as a separate worker.
For Railway or Render: the web service can run the scheduler as part of the same process, and the managed PostgreSQL add-on handles the database.
Key Takeaways
node-cronhandles the scheduling; Prisma handles persistence; Axios handles the HTTP calls. The architecture is minimal and each part is independently replaceable.Validate cron expressions before registering jobs to prevent the scheduler from crashing on malformed input.
Use
axios.isAxiosError()for type-safe error handling rather than casting toany.Log every execution including failures, with the HTTP status code and duration. This is the visibility that makes the scheduler useful in practice.
Authentication on all API routes is essential since the dashboard exposes the ability to trigger arbitrary HTTP requests.
The correct deployment approach depends on the platform. Serverless platforms require a different trigger mechanism than persistent server deployments.
Conclusion
A self-hosted API scheduler built on this stack is a few hundred lines of TypeScript and provides complete visibility and control over scheduled API calls. The logs table answers the questions that matter in practice: did the call succeed, how long did it take, and if it failed, what was the error.
The architecture scales in the expected directions: additional endpoint types, retry logic with exponential backoff, alert notifications on failure, and role-based access control are all straightforward additions to the same foundation.
Building something similar or extending this with a specific feature? Share what you are working on in the comments.




