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.
A small client
Section titled “A small client”Keep the key on the server. Every call goes through one function that adds the header and turns error bodies into exceptions.
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.
The job page
Section titled “The job page”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_htmlis sanitized by SimplifyLocal and safe to insert. It isnullonly on list rows, never on this endpoint, but the check costs nothing.schema_orggoes in verbatim. Do not merge your own fields into it.noindex: truemust reach the<meta name="robots">tag; the snippet does that throughgenerateMetadata.- A
410means the business is closed; treating it as not-found is correct.
The index page
Section titled “The index page”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).
Keeping pages fresh
Section titled “Keeping pages fresh”Register a webhook endpoint (Webhooks) pointing at a route handler that verifies the signature and revalidates the tag:
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.
Static export
Section titled “Static export”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.