This builds a working niche job board in Next.js 15 that reads live, normalized postings from the API, renders them as listing and detail pages, and stays fresh with incremental static regeneration — without scraping a single careers page. Every file below is complete and runnable. Paste them in order and you have a board.
What you'll build
A focused board — say, engineering roles in one country — is the sweet spot for this stack. You query the API for exactly the slice you want, render server-side so your key never touches the browser, and let ISR re-fetch on a 30-minute timer that matches how often the dataset itself refreshes. No scraper, no queue, no database required to start. Three files do the work:
lib/jobs.ts— the one typed module that talks to the API.app/jobs/page.tsx— the listing, rendering one card per role.app/jobs/[id]/page.tsx— the detail page, with the full description and an apply link.
Setup
Start from a fresh Next.js 15 app with the App Router. If you do not have one, npx create-next-app@latest and accept the App Router default. Then add your API key as a server-side environment variable.
# .env.local — server-side only. Never prefix with NEXT_PUBLIC_:
# that would inline the key into the client bundle and leak it.
JLA_API_KEY=jla_live_your_key_hereThe naming matters. Next inlines any variable prefixed with NEXT_PUBLIC_ into the client bundle, where anyone can read it. The API key must stay server-only, so it gets a plain name and is only ever read inside server code. Grab a free key from the dashboard if you do not have one — 5,000 requests a month, no card.
Fetching jobs
Put every API call in one module so the key, the types, and the cache policy live in a single place. The fetch is typed against the response schema, and next: { revalidate: 1800 } caches each response for 30 minutes:
// lib/jobs.ts — the only place that talks to the API.
// The key is read on the server; it never reaches the browser.
const API_BASE = "https://api.joblistingsapi.com/v1";
export type Job = {
id: number;
title: string;
company: string;
location: { raw: string; city: string | null; country_code: string | null };
is_remote: boolean;
role_category: string | null;
seniority: string | null;
listed_at: string | null;
description_html: string | null;
url: string;
source: string;
};
type JobsResponse = { jobs: Job[]; next_cursor: string | null; total?: number };
function apiKey(): string {
const key = process.env.JLA_API_KEY;
if (!key) throw new Error("JLA_API_KEY is not set");
return key;
}
// One typed fetch. revalidate: 1800 lets Next cache the response for 30 minutes
// — the same cadence the dataset refreshes on, so you never serve staler data
// than the source has, and you never hammer the API on every request.
async function getJobs(params: Record<string, string>): Promise<JobsResponse> {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}/jobs?${qs}`, {
headers: { "X-API-Key": apiKey() },
next: { revalidate: 1800 },
});
if (!res.ok) {
throw new Error(`API ${res.status}: ${await res.text()}`);
}
return res.json();
}
// Our board: senior-ish engineering roles in the UK.
//
// role_category is a normalized-taxonomy filter and needs Growth or higher.
// On the free tier, drop role_category and use ?title=engineer instead — a
// case-insensitive substring match on the title, available on every plan. It is
// coarser (it will not catch "Backend Developer"), but it costs nothing.
export async function listJobs(): Promise<Job[]> {
const { jobs } = await getJobs({
role_category: "engineering", // free tier: replace with title: "engineer"
country: "GB",
limit: "50",
});
return jobs;
}
// One job by id, for the detail page.
export async function getJob(id: string): Promise<Job | null> {
const res = await fetch(`${API_BASE}/jobs/${id}`, {
headers: { "X-API-Key": apiKey() },
next: { revalidate: 1800 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
return res.json();
}One thing to note on filters. role_category is the normalized role taxonomy and is gated to Growth and up. On the free tier, drop it and filter with ?title=engineer instead — a case-insensitive substring match available on every plan. It is coarser, but it costs nothing and is the right starting point. The comment in the code marks exactly where to swap.
Listing page
The board itself is a server component. Because it runs on the server, the call to listJobs() — and the API key it uses — never ships to the browser; the client receives only rendered HTML. Each card shows the title, company, location, and posted date, and links to the detail page:
// app/jobs/page.tsx — the board. A server component, so the API call and the
// key stay on the server; the browser only receives rendered HTML.
import Link from "next/link";
import { listJobs } from "@/lib/jobs";
export const metadata = { title: "Engineering jobs" };
export default async function JobsPage() {
const jobs = await listJobs();
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Engineering jobs in the UK</h1>
<p>{jobs.length} open roles, refreshed every 30 minutes.</p>
<ul style={{ listStyle: "none", padding: 0 }}>
{jobs.map((job) => (
<li
key={job.id}
style={{ borderBottom: "1px solid #eee", padding: "1rem 0" }}
>
<Link href={`/jobs/${job.id}`} style={{ fontWeight: 600 }}>
{job.title}
</Link>
<div style={{ color: "#555" }}>
{job.company} · {job.is_remote ? "Remote" : job.location.raw}
</div>
{job.listed_at && (
<div style={{ color: "#999", fontSize: 13 }}>
Posted {new Date(job.listed_at).toLocaleDateString()}
</div>
)}
</li>
))}
</ul>
</main>
);
}Detail pages
The detail page renders the full description from description_html, which is available on Starter and up. The HTML arrives as the employer published it on their ATS, so treat it like any third-party input: run it through a sanitizer such as sanitize-html or DOMPurify before handing it to dangerouslySetInnerHTML, as the snippet does.
The apply link is the important part. You link out to the job's url field — the original posting on the source ATS — as the apply path. That is exactly the legitimate use of the data on your own board: you surface the role, the applicant applies at the source. rel="nofollow" keeps your board from vouching for every employer's domain:
// app/jobs/[id]/page.tsx — one posting, with the full description.
// npm install sanitize-html (and @types/sanitize-html)
import { notFound } from "next/navigation";
import sanitize from "sanitize-html";
import { getJob } from "@/lib/jobs";
export default async function JobDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const job = await getJob(id);
if (!job) notFound();
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>{job.title}</h1>
<p style={{ color: "#555" }}>
{job.company} · {job.is_remote ? "Remote" : job.location.raw}
</p>
{/* description_html arrives as published by the employer's ATS. Treat it
as untrusted input: run it through a sanitizer (DOMPurify,
sanitize-html) before rendering. */}
{job.description_html && (
<div
dangerouslySetInnerHTML={{ __html: sanitize(job.description_html) }}
/>
)}
{/* The apply path: link out to the original posting on the source ATS.
rel="nofollow" keeps your board from passing link equity to every
employer, and noopener is the safe default for target=_blank. */}
<a
href={job.url}
rel="nofollow noopener"
target="_blank"
style={{ display: "inline-block", marginTop: "1.5rem", fontWeight: 600 }}
>
Apply on {job.company}’s site →
</a>
</main>
);
}Keeping it fresh
ISR is doing the work already: revalidate: 1800 means Next serves a cached page, and the first request after 30 minutes triggers a background re-fetch. That alone keeps your board within one refresh of the source.
If you want tighter control — pulling only changed records, re-indexing a search store, warming specific pages — add a cron route that syncs deltas with updated_since. On Vercel you wire it up in vercel.json with a crons entry; anywhere else, a scheduler that hits the protected route on an interval:
// app/api/refresh/route.ts — optional. ISR already revalidates on a timer,
// but a cron hit lets you pull deltas and warm specific pages on your schedule.
import { NextResponse } from "next/server";
const API_BASE = "https://api.joblistingsapi.com/v1";
export async function GET(request: Request) {
// Protect the route so only your scheduler can call it.
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Ask only for what changed since an hour ago — updated_since is on every
// plan, so delta sync costs you nothing extra.
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const res = await fetch(
`${API_BASE}/jobs?updated_since=${since}&limit=100`,
{ headers: { "X-API-Key": process.env.JLA_API_KEY! }, cache: "no-store" },
);
const { jobs } = await res.json();
// Persist or re-index the changed jobs here, then report how many moved.
return NextResponse.json({ changed: jobs.length });
}Delta sync with updated_since is on every plan, so polling for changes never costs more than the changes themselves.
Deploy notes
- Set the key as a real env var, not a file. Add
JLA_API_KEYin your host's environment settings (Vercel, Railway, Fly). It stays server-side; it is never exposed to the client because nothing reads it client-side. - ISR works on any Node host. The
revalidatetimer runs wherever Next runs; you do not need Vercel specifically for the 30-minute refresh. - Mind the 21-day window. The dataset holds a rolling 21-day active window, so a board that only ever reads the live endpoint naturally shows current roles and ages old ones out. If you want a longer history on your board, persist postings as you see them.
- Respect rate limits. The free tier allows 10 requests a minute. ISR keeps you far under that for a single board; if you fan out to many filters, cache aggressively or move up a tier.
See the docs for the full response schema and every filter, or analyze the same data in Python if you want charts instead of a board.