Skip to content

Render pages with Next.js

This guide builds /work and /work/[jobSlug] in a Next.js App Router project from the Publish API. It assumes a tenant key in SL_API_KEY and your business slug in SL_BUSINESS_SLUG.

Keep the key on the server. Every call goes through one function that adds the header and turns error bodies into exceptions.

lib/simplifylocal.ts
import "server-only";
const BASE = `https://app.simplifylocal.app/api/businesses/${process.env.SL_BUSINESS_SLUG}`;
export class SimplifyLocalError extends Error {
constructor(public status: number, public code: string) {
super(`${status} ${code}`);
}
}
async function get<T>(path: string, revalidate: number | false): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { Authorization: `Bearer ${process.env.SL_API_KEY}` },
next: { revalidate, tags: ["simplifylocal"] },
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: "unknown" }));
throw new SimplifyLocalError(res.status, body.error);
}
return res.json();
}
export const getBusiness = () => get<Business>("", 3600);
export const listJobs = (cursor?: string) =>
get<ListJobsResponse>(`/jobs?limit=100&indexable=true${cursor ? `&cursor=${cursor}` : ""}`, 300);
export const getJob = (slug: string) =>
get<JobDetailResponse>(`/jobs/${encodeURIComponent(slug)}`, 300);

Business, ListJobsResponse and JobDetailResponse are described in Objects.

The revalidate values are a safety net; the webhook handler below is what keeps pages current. Next’s data cache also keys on the full request, so the ETag is handled for you when the fetch is re-run.

app/work/[jobSlug]/page.tsx
import { notFound } from "next/navigation";
import { getJob, SimplifyLocalError } from "@/lib/simplifylocal";
export async function generateMetadata({ params }: { params: Promise<{ jobSlug: string }> }) {
const { jobSlug } = await params;
try {
const { job } = await getJob(jobSlug);
return {
title: job.headline,
description: job.meta_description,
robots: job.noindex ? { index: false } : undefined,
openGraph: job.og_image_url ? { images: [job.og_image_url] } : undefined,
};
} catch {
return {};
}
}
export default async function JobPage({ params }: { params: Promise<{ jobSlug: string }> }) {
const { jobSlug } = await params;
let data;
try {
data = await getJob(jobSlug);
} catch (err) {
if (err instanceof SimplifyLocalError && (err.status === 404 || err.status === 410)) notFound();
throw err;
}
const { job, business } = data;
return (
<article>
<h1>{job.headline}</h1>
<p>
{job.service_type_display} in {job.service_city_display}, {job.service_state}
</p>
{job.body_html ? (
<div dangerouslySetInnerHTML={{ __html: job.body_html }} />
) : null}
{job.related_jobs.length > 0 && (
<aside>
<h2>More work like this</h2>
<ul>
{job.related_jobs.map((r) => (
<li key={r.slug}>
<a href={`${business.jobs_base_path}/${r.slug}`}>{r.headline}</a>
</li>
))}
</ul>
</aside>
)}
{job.schema_org && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(job.schema_org) }}
/>
)}
</article>
);
}

Notes:

  • body_html is sanitized by SimplifyLocal and safe to insert. It is null only on list rows, never on this endpoint, but the check costs nothing.
  • schema_org goes in verbatim. Do not merge your own fields into it.
  • noindex: true must reach the <meta name="robots"> tag; the snippet does that through generateMetadata.
  • A 410 means the business is closed; treating it as not-found is correct.
app/work/page.tsx
import { listJobs } from "@/lib/simplifylocal";
export default async function WorkIndex() {
const { jobs, business, aggregates } = await listJobs();
return (
<main>
<h1>Recent work{aggregates ? ` (${aggregates.total_jobs})` : ""}</h1>
<ul>
{jobs.map((j) => (
<li key={j.slug}>
<a href={`${business.jobs_base_path}/${j.slug}`}>
{j.media[0] && <img src={j.media[0].url} alt={j.media[0].alt} width={j.media[0].width} height={j.media[0].height} />}
{j.headline}
</a>
</li>
))}
</ul>
</main>
);
}

List rows carry headline, photos and classification but no body; see List jobs. To render more than 100 jobs, follow pagination.next_cursor with aggregates=false (Pagination).

Register a webhook endpoint (Webhooks) pointing at a route handler that verifies the signature and revalidates the tag:

app/api/simplifylocal/route.ts
import { revalidateTag } from "next/cache";
import { verifySimplifyLocal } from "@/lib/verify-simplifylocal";
export async function POST(req: Request) {
const rawBody = await req.text();
const ok = verifySimplifyLocal({
secret: process.env.SL_WEBHOOK_SECRET!,
rawBody,
headers: Object.fromEntries(req.headers),
});
if (!ok) return new Response(null, { status: 401 });
revalidateTag("simplifylocal");
return new Response(null, { status: 200 });
}

verifySimplifyLocal is the Node function from Verify a webhook signature. Revalidating one tag for every event is the simplest correct approach; for per-URL purging on a CDN in front of Next, see Invalidate your CDN.

For output: "export" there is no request-time fetch. Generate the routes with generateStaticParams from List jobs (or the Build-time feed if you only need slugs), fetch each job at build time, and trigger a rebuild from the webhook instead of calling revalidateTag.