Combine Next.js with CleanJobData to build a fast, SEO-optimized job board.
Fetch job listings in a Next.js server component.
const res = await fetch('https://api.cleanjobdata.com/jobs', {
headers: { 'X-API-Key': process.env.CLEANJOBDATA_API_KEY }
});
const { data: jobs } = await res.json();Add ISR with hourly revalidation for fast page loads with fresh data.
export const revalidate = 3600;Generate unique title and description for every page.
export async function generateMetadata() {
return { title: 'Software Engineer Jobs | My Board', description: '...' };
}Real-world patterns beyond the basic setup.
A full server component with TypeScript types, searchParams-driven filtering, and ISR — copy-pasteable, no missing imports. Adjust the field shape to match whichever `fields` you request from the API.
import { Metadata } from 'next';
import Link from 'next/link';
interface Job {
id: string;
title: string;
company: { name: string };
location: string;
salary_min?: number;
salary_max?: number;
published: string;
}
interface PageProps {
searchParams: Promise<{ title?: string; location?: string }>;
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const { title } = await searchParams;
const query = title || 'Software Engineer';
return {
title: `${query} Jobs | My Job Board`,
description: `Browse the latest ${query} positions, updated hourly.`,
};
}
async function getJobs(title?: string, location?: string): Promise<Job[]> {
const params = new URLSearchParams({ limit: '20' });
if (title) params.set('title', title);
if (location) params.set('location', location);
const res = await fetch(`https://api.cleanjobdata.com/jobs?${params.toString()}`, {
headers: { 'X-API-Key': process.env.CLEANJOBDATA_API_KEY! },
next: { revalidate: 3600 }, // ISR: revalidate hourly
});
if (!res.ok) throw new Error(`CleanJobData API ${res.status}`);
const json = await res.json();
return json.data as Job[];
}
export default async function JobsPage({ searchParams }: PageProps) {
const { title, location } = await searchParams;
const jobs = await getJobs(title, location);
return (
<main className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Latest Job Listings</h1>
<form className="flex gap-2 mb-8">
<input name="title" defaultValue={title} placeholder="Job title or skill..." className="border p-2 rounded w-full" />
<input name="location" defaultValue={location} placeholder="City, state, or country" className="border p-2 rounded w-full" />
<button type="submit" className="bg-black text-white px-4 py-2 rounded">Search</button>
</form>
<div className="space-y-4">
{jobs.map((job) => (
<div key={job.id} className="border p-4 rounded hover:border-black transition">
<h2 className="text-xl font-semibold">{job.title}</h2>
<p className="text-gray-600">{job.company.name} • {job.location}</p>
<Link href={`/jobs/${job.id}`} className="text-blue-600 mt-2 inline-block">View Details →</Link>
</div>
))}
</div>
</main>
);
}Job pages are transient — a static sitemap goes stale within days. Next.js's sitemap.ts route regenerates it from live data on a schedule, which is exactly what the starter repo's own app/sitemap.ts does.
import { MetadataRoute } from 'next';
interface JobSitemapEntry {
id: string;
published: string;
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const res = await fetch('https://api.cleanjobdata.com/jobs?limit=500&fields=id,published', {
headers: { 'X-API-Key': process.env.CLEANJOBDATA_API_KEY! },
next: { revalidate: 86400 }, // rebuild once a day
});
const { data: jobs } = (await res.json()) as { data: JobSitemapEntry[] };
const jobUrls = jobs.map((job) => ({
url: `https://myjobboard.com/jobs/${job.id}`,
lastModified: new Date(job.published),
}));
return [
{ url: 'https://myjobboard.com', lastModified: new Date() },
{ url: 'https://myjobboard.com/jobs', lastModified: new Date() },
...jobUrls,
];
}Set your key locally before running the starter or your own integration.
# .env.local
CLEANJOBDATA_API_KEY=cjd_live_your_api_key_hereYes. Call the API directly from a Server Component or Route Handler so your key never reaches the client: `const res = await fetch('https://api.cleanjobdata.com/jobs?title=engineer&remote=true', { headers: { 'X-API-Key': process.env.CLEANJOBDATA_API_KEY }, next: { revalidate: 3600 } })`. Pages Router works the same way from getServerSideProps or an API route — the API is plain REST, so nothing here is App Router-specific.
Yes, and it's the standard pattern for a job board built on this API. Set `revalidate` on the fetch call (or `export const revalidate = 3600` on the route segment) to control how stale a page can get. An hour matches how often most listings actually change; drop it to a few hundred seconds for pages that need near-live data, and watch the `X-RateLimit-Remaining` response header if you tighten it further.
Add a Route Handler that proxies the request server-side: `app/api/jobs/route.ts` reads `searchParams` off the incoming request, calls CleanJobData with `process.env.CLEANJOBDATA_API_KEY` (never exposed to the browser), and returns the JSON. Your client component then calls `/api/jobs?...` — the same-origin route — instead of the CleanJobData API directly, so the key never appears in browser network traffic or bundled JS.
If pages are served through ISR (`next: { revalidate: 3600 }` or a route segment's `revalidate` export), Next.js keeps serving the last successfully cached page to visitors even if a background revalidation fetch fails or gets rate-limited — users don't see an error, and the next successful revalidation catches the page back up. This only holds for ISR'd pages; a Route Handler proxying live, uncached requests (like the search-widget pattern above) will surface the rate-limit error directly, so add basic retry/backoff there if you expect bursty traffic.