# Careers Page Template in React: Apply Form and JobPosting

> A careers page template in React that gets applications: mission, team, benefits, filterable roles, a job drawer, an apply form and JobPosting data for Google.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/careers-page-template

## TL;DR

A careers page template that gets applications needs a short mission, real people, benefits, the hiring steps, a filterable list of roles with salary ranges, and a page per role with an apply form that reaches your inbox or ATS. Put JobPosting structured data on each role page, never on the list, and return a 404 once a role is filled. Wingo UI's Careers Page and Job Detail Page templates render the UI; you supply the roles, the copy and the application handler.

A careers page has one job: turn a curious visitor into an application you can answer. Most careers page templates stop at the look. They give you a hero, a values grid and a list of roles, and leave out the parts that decide whether anyone applies or whether Google lists your jobs at all. This guide covers a careers page template in React and Next.js that does the whole job: a mission, the team, benefits, filterable roles, a job drawer, an apply form, a page per role and JobPosting structured data. The UI is our [Careers Page template](https://wingo-ui.com/templates/careers-page), so we are not neutral, but the data model, the routes and the markup work with any job page template.

## What should a careers page template include?

A careers page template should answer, in order, the questions a candidate has before applying: why the company exists, who they would work with, how people work and what they get, how hiring works, which roles are open at what pay, and what to do when no role fits. When we searched "careers page template" on November 6, 2026, the results were mostly no-code and page-builder templates (involve.me, WeWeb, Notion, Webflow's careers list, a Framer job board theme), a printable worksheet and the career site builders inside applicant tracking systems such as Workable. Variations like "careers page template react", "careers page design template" or "careers page example" turn up more of the same kind of page. They are good at layout. Almost none of them say where the application goes or how a role shows up in Google's job results.

Here is what each section is for, and the prop that controls it in `CareersPage`:

| Section | The question it answers | In the template |
|---|---|---|
| Hero | Why does this company exist, in one paragraph? | `labels.heroTitle`, `labels.heroDescription` |
| Numbers | How big and how old is it? | `stats`, `showStats` |
| Team | Who would I work with? | `team`, `moments`, `showTeam` |
| Values and benefits | How do people work here, and what do I get? | `values`, `perks` |
| Hiring process | What happens after I apply, and how long does it take? | `steps`, `showProcess` |
| Open roles | Is there a role for me, where, and for how much? | `roles`, `departments`, `groupByDepartment` |
| Open application | What if no role fits? | `showCta`, `labels.ctaPrimary` |

Two of these carry more weight than they look. Group roles by team: [Workable's careers page UX guide](https://resources.workable.com/tutorial/careers-page-ux) (updated September 2023) recommends it, and it is what `groupByDepartment` does while All is selected. And show the salary range, because in several US states the law requires it: [Paycor's state list](https://paycor.com/resource-center/articles/pay-transparency-laws-by-state) (updated November 20, 2025) shows California, Colorado, Illinois, New York and Washington requiring pay information in job postings. Colorado applies it to every employer; the other four set a minimum employer size (4 employees in New York, 15 in the rest). Check the rules for every state you hire in.

## How do you install the careers page template?

One command copies both templates and everything they import. They are Pro items, so the account behind the CLI needs Wingo UI Pro.

```bash
npx wingo-ui@latest add careers-page job-detail-page
```

You get `components/templates/careers-page.tsx` and `careers-page-data.ts`, `job-detail-page.tsx` and `job-detail-page-data.ts`, and their parts: the free [Drawer](https://wingo-ui.com/components/drawer) for the job panel, the free [Search Input](https://wingo-ui.com/components/search-input) for the role filter, the free [Empty State](https://wingo-ui.com/components/empty-state) for "No roles match those filters", the [File Upload](https://wingo-ui.com/components/file-upload) for the resume and the [Team Section](https://wingo-ui.com/components/team-section). With no props, the page renders nine sample roles of a made-up company called Acme.

> Live demo (Careers Page): Type "design" in the search field, pick a department chip, then click a role: the job opens in a side panel with the apply form. On a phone the panel is a bottom sheet you can drag down to close. Try it at [Careers Page](https://wingo-ui.com/templates/careers-page) and install it with `npx wingo-ui@latest add careers-page`.

The list does three things a static list does not. Search, department chips and a location select narrow the roles as you type, and each chip shows how many roles it matches. A click on a role opens a drawer with the description, the requirements, your benefits and the form, so a visitor can read three roles without losing the filter. And the row is still a real link to `/careers/<slug>`: Cmd or Ctrl click opens the page, and crawlers follow it.

## How do you replace the sample content?

Keep your roles in one typed file that the list, the role pages and the structured data all read. The `JobDetail` type of the job page already holds everything the careers list needs, so one array feeds both templates. Add the two things structured data needs that a display row does not: where applicants may live for remote roles, and the office address for the rest.

```ts
// app/careers/jobs.ts
import type { JobDetail } from "@/components/templates/job-detail-page-data"

type Office = { locality: string; region?: string; country: string }

export type Job = JobDetail & {
  postedAt: string
  // leave out when the role stays open until filled
  validThrough?: string
} & (
    | { workplace: "Remote"; eligibleCountries: string[] }
    | { workplace: "Hybrid" | "On-site"; office: Office }
  )

export const JOBS: Job[] = [
  {
    slug: "senior-frontend-engineer",
    title: "Senior Frontend Engineer",
    department: "Engineering",
    location: "Remote, US",
    workplace: "Remote",
    eligibleCountries: ["US"],
    type: "Full-time",
    salaryMin: 160000,
    salaryMax: 190000,
    postedAt: "2026-11-02",
    isNew: true,
    summary: "Own the editor that our customers open every morning.",
    about: ["You will join a team of four engineers and one designer."],
    responsibilities: ["Build the editor in React and TypeScript"],
    requirements: ["5+ years building web apps, 3+ with React"],
  },
]
```

Then render the list from a small client wrapper, because `onApply` is a function and the router link is a client component:

```tsx
// app/careers/careers-view.tsx
"use client"

import Link from "next/link"
import { CareersPage } from "@/components/templates/careers-page"
import { JOBS } from "./jobs"
import { MOMENTS, PERKS, STEPS, TEAM, VALUES } from "./content"
import { sendApplication } from "./send-application"

export function CareersView() {
  return (
    <CareersPage
      roles={JOBS}
      departments={["Engineering", "Design", "Customer success"]}
      values={VALUES}
      perks={PERKS}
      team={TEAM}
      moments={MOMENTS}
      steps={STEPS}
      showStats={false}
      labels={{
        heroEyebrow: "Careers at Acme",
        heroDescription: "We are six people building a planning tool for small teams, from Brooklyn and three US time zones.",
        processTitle: "Three steps, about two weeks",
        ctaEmail: "jobs@acme.com",
      }}
      linkComponent={Link}
      // the site layout already renders the header and footer
      header={false}
      footer={false}
      onApply={sendApplication}
    />
  )
}
```

`content.ts` holds your values, perks, team, moments and steps, typed with `CareersValue`, `CareersPerk`, `CareersTeamMember`, `CareersMoment` and `CareersStep` from `careers-page-data.ts`. Go through the default labels too. The sample copy promises things like "We reply to every application within five working days" and a paid take-home. Those are fine promises if you keep them and a bad first impression if you do not. The sample numbers (42 teammates, 11 countries) are why `showStats={false}` is in the example: turn it back on when you have figures you are proud of.

## Why does every role need its own page?

The drawer is a shortcut for people. The page is for everyone else: the link a recruiter pastes into a message, the URL your ATS emails, and the only place Google accepts job markup. Google's [job posting structured data guide](https://developers.google.com/search/docs/appearance/structured-data/job-posting) (checked November 6, 2026) says JobPosting must only be used on pages that contain a single job posting, and not on pages that list jobs. So the careers list carries no JobPosting, and each `/careers/<slug>` page carries exactly one.

```tsx
// app/careers/[slug]/page.tsx
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { JOBS } from "../jobs"
import { jobPostingJsonLd } from "../job-posting"
import { JobView } from "./job-view"

type Props = { params: Promise<{ slug: string }> }

// a slug that is not in JOBS is a 404, so a removed role drops out of Google.
// with cacheComponents on, Next.js 16 drops this option and the notFound() below does the job
export const dynamicParams = false

export function generateStaticParams() {
  return JOBS.map((job) => ({ slug: job.slug }))
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const job = JOBS.find((item) => item.slug === slug)
  if (!job) return {}
  return {
    title: `${job.title} (${job.location})`,
    description: job.summary,
    alternates: { canonical: `/careers/${job.slug}` },
  }
}

export default async function JobRoute({ params }: Props) {
  const { slug } = await params
  const job = JOBS.find((item) => item.slug === slug)
  if (!job) notFound()

  const similar = JOBS.filter((item) => item.department === job.department && item.slug !== job.slug)
  const jsonLd = jobPostingJsonLd(job, `https://acme.com/careers/${job.slug}`)

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
      />
      <JobView job={job} similar={similar} />
    </>
  )
}
```

```tsx
// app/careers/[slug]/job-view.tsx
"use client"

import Link from "next/link"
import { JobDetailPage } from "@/components/templates/job-detail-page"
import type { JobDetail, JobRole } from "@/components/templates/job-detail-page-data"
import { sendApplication } from "../send-application"

export function JobView({ job, similar }: { job: JobDetail; similar: JobRole[] }) {
  return (
    <JobDetailPage
      role={job}
      similarRoles={similar}
      linkComponent={Link}
      header={false}
      footer={false}
      onApply={sendApplication}
    />
  )
}
```

In Next.js 16, `params` is a promise, so both functions await it. The JSON-LD script sits in the server page, so it is in the first HTML response for crawlers that never run JavaScript. Our [Next.js JSON-LD guide](https://wingo-ui.com/blog/nextjs-json-ld) explains the `<` escape and how to keep markup in sync with the page.

> Live demo (Job Detail Page): Scroll the role: on desktop the apply card sticks beside the description. Below 1024px a bottom bar with Apply now appears; tap it, then submit the empty form to see the first missing field get focus. Try it at [Job Detail Page](https://wingo-ui.com/templates/job-detail-page) and install it with `npx wingo-ui@latest add job-detail-page`.

## How do you add JobPosting structured data for Google jobs?

Google requires five properties: `title`, `description` (the full description as HTML), `datePosted`, `hiringOrganization` and `jobLocation`, and a fully remote role may replace `jobLocation` with `applicantLocationRequirements`. The ones worth adding are `validThrough`, `employmentType`, `baseSalary`, `identifier`, `directApply`, and for remote roles `jobLocationType` with `applicantLocationRequirements`. Build them from the same `Job` the page renders, so the markup never says something the reader cannot see:

```ts
// app/careers/job-posting.ts
import type { Job } from "./jobs"

const EMPLOYMENT = { "Full-time": "FULL_TIME", "Part-time": "PART_TIME", Contract: "CONTRACTOR" } as const

const escapeHtml = (text: string) =>
  text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
const list = (items: string[]) => `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`

export function jobPostingJsonLd(job: Job, url: string) {
  // the same text the page shows, as html
  const description = [
    ...job.about.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`),
    `<h3>What you'll do</h3>${list(job.responsibilities)}`,
    `<h3>What you'll bring</h3>${list(job.requirements)}`,
  ].join("")

  return {
    "@context": "https://schema.org",
    "@type": "JobPosting",
    title: job.title,
    description,
    datePosted: job.postedAt,
    validThrough: job.validThrough,
    employmentType: EMPLOYMENT[job.type],
    url,
    directApply: true,
    identifier: { "@type": "PropertyValue", name: "Acme", value: job.slug },
    hiringOrganization: {
      "@type": "Organization",
      name: "Acme",
      sameAs: "https://acme.com",
      logo: "https://acme.com/logo.png",
    },
    ...(job.workplace === "Remote"
      ? {
          jobLocationType: "TELECOMMUTE",
          applicantLocationRequirements: job.eligibleCountries.map((name) => ({ "@type": "Country", name })),
        }
      : {
          jobLocation: {
            "@type": "Place",
            address: {
              "@type": "PostalAddress",
              addressLocality: job.office.locality,
              addressRegion: job.office.region,
              addressCountry: job.office.country,
            },
          },
        }),
    ...(job.salaryMin && job.salaryMax
      ? {
          baseSalary: {
            "@type": "MonetaryAmount",
            currency: job.salaryCurrency ?? "USD",
            value: { "@type": "QuantitativeValue", minValue: job.salaryMin, maxValue: job.salaryMax, unitText: "YEAR" },
          },
        }
      : {}),
  }
}
```

A few rules from the same Google guide decide whether this works:

- `TELECOMMUTE` is only for roles that are 100% remote, and the description must clearly say so, so put that sentence in `about`. A hybrid role with two office days keeps its office address in `jobLocation`, which is why the `Job` type makes `office` required for anything that is not Remote.
- A remote role needs at least one country applicants may live in, through `applicantLocationRequirements`.
- `baseSalary` is the employer's actual range, with `unitText` as `HOUR`, `DAY`, `WEEK`, `MONTH` or `YEAR` (case-sensitive).
- When a role is filled, take it down: a 404 or 410, no markup, or a `validThrough` in the past. Deleting it from `JOBS` does the first, because the route only knows the slugs in `JOBS` and calls `notFound()` for the rest. Google warns that stale jobs can earn a manual action.

Google lists the countries where job results appear; it includes all of North America and Latin America, plus the United Kingdom, Germany, France and others in Europe. Test a deployed role page with the [Rich Results Test](https://search.google.com/test/rich-results) before you count on it.

## Where should the applications go?

Applications go wherever your `onApply` handler sends them: a route handler on your own server that stores them or forwards them to your ATS. Without `onApply`, both templates wait 900 ms and show the confirmation. That is right for a demo and a silent failure on a live site, so wire it before launch. The helper below works for both templates, because their application objects share these fields. The job page also sends `consent`, the answer to its "keep my application for 12 months" checkbox, which you should store with the application:

```ts
// app/careers/send-application.ts
type Application = {
  role?: { slug: string }
  name: string
  email: string
  linkedin: string
  note: string
  resume?: File
  // only the job page sends it
  consent?: boolean
}

// a rejection makes the form show its "failed" message
export async function sendApplication(application: Application) {
  const body = new FormData()
  body.set("role", application.role?.slug ?? "open")
  body.set("name", application.name)
  body.set("email", application.email)
  body.set("linkedin", application.linkedin)
  body.set("note", application.note)
  if (application.consent !== undefined) body.set("consent", String(application.consent))
  if (application.resume) body.set("resume", application.resume)
  const response = await fetch("/api/applications", { method: "POST", body })
  if (!response.ok) throw new Error(`Application failed: ${response.status}`)
}
```

Send it to a route handler, not a Server Action. The resume field accepts PDF and Word files up to 10 MB, and the Next.js 16 docs cap Server Action requests at 1 MB unless you raise `serverActions.bodySizeLimit`. Check your host's request size limit as well.

```ts
// app/api/applications/route.ts
const MAX_RESUME = 10 * 1024 * 1024
const RESUME_TYPES = [
  "application/pdf",
  "application/msword",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]

export async function POST(request: Request) {
  const form = await request.formData()
  const name = String(form.get("name") ?? "").trim()
  const email = String(form.get("email") ?? "").trim()
  const resume = form.get("resume")

  if (!name || !/^\S+@\S+\.\S+$/.test(email)) {
    return Response.json({ error: "Name and a valid email are required" }, { status: 400 })
  }
  if (resume instanceof File && (resume.size > MAX_RESUME || !RESUME_TYPES.includes(resume.type))) {
    return Response.json({ error: "The resume must be a PDF or Word file up to 10 MB" }, { status: 400 })
  }

  // store it, or forward it to your ATS with the secret key that never reaches the browser
  return Response.json({ ok: true }, { status: 201 })
}
```

If you already use an ATS, let it stay the source of truth. Greenhouse is a good example: its [Job Board API](https://docs.greenhouse.io/job-board.html) (checked November 6, 2026) serves your jobs publicly with no authentication, and `content=true` adds the full description, department and office, so `JOBS` can be a fetch at build time. Its application endpoint needs your Job Board API key over Basic Auth, and the docs say to proxy form posts through your own server. That is the comment in the handler above. The [Next.js contact form guide](https://wingo-ui.com/blog/nextjs-contact-form) covers spam protection and email delivery for the same kind of handler.

## How does the careers page behave on a phone?

On a phone the templates change shape instead of shrinking. The department chips scroll sideways in one row below 640px. Below 768px the location select opens a bottom sheet. The job drawer becomes a bottom sheet you drag down to close, and the resume field takes a tap to pick a file. On the job page, the sticky apply card turns into a bottom bar below 1024px, above the safe area, and only while the form is out of view.

The keyboard works too: `/` focuses the search field, Enter on a role opens the drawer, Escape closes it, and on the job page a failed submit moves focus to the first field with a problem. Role rows are real links, so screen readers announce them as links and middle click opens a new tab.

## Should the team section live on the careers page or the about page?

Both, with different jobs. On the careers page, the team answers "who would I work with?", and the `bold` design swaps the photo mosaic for the [Team Section](https://wingo-ui.com/components/team-section) with its "We're hiring" tile. On your about page, the same block links back to careers and counts the open roles:

```tsx
// app/about/team.tsx
import { TeamSection } from "@/components/blocks/team-section"
import { JOBS } from "@/app/careers/jobs"

export function AboutTeam() {
  return (
    <TeamSection
      title="The people building Acme"
      description="Six people in Brooklyn and across three US time zones."
      members={[{ name: "Emma Carter", role: "Founder" }]}
      layout="cards"
      columns={3}
      openRoles={JOBS.length}
      hiring={JOBS.length > 0}
      hiringHref="/careers"
    />
  )
}
```

> Live demo (Team Section): The cards layout with three open roles: the last tile links to the careers page and counts them. On a phone the cards stack in one column. Try it at [Team Section](https://wingo-ui.com/components/team-section) and install it with `npx wingo-ui@latest add team-section`.

Set `hiring={false}` when nothing is open, so the tile does not invite people to an empty list. Our [SaaS website template guide](https://wingo-ui.com/blog/saas-website-template) covers how careers fits with the other pages of a company site, and the [build category](https://wingo-ui.com/blog/category/build) has more screens built the same way.

## What are the common careers page mistakes?

- **Sample copy left in.** Acme's 40,000 teams and its five-day reply promise are not yours. Search the labels for "Acme" before launch.
- **No salary ranges.** Several US states require them in job postings, and a range in the list saves an interview round for both sides when the pay does not fit.
- **Expired roles still live.** One array, one 404 when a role leaves it.
- **JobPosting on the list page.** Google does not accept it there; one posting per role page.
- **An apply form that goes nowhere.** Test `onApply` with a real file, and make it reject on failure so the form says so.

## Next step

Click through the [Careers Page](https://wingo-ui.com/templates/careers-page) and the [Job Detail Page](https://wingo-ui.com/templates/job-detail-page) in all their designs, then install them with `npx wingo-ui@latest add careers-page job-detail-page` and replace the sample roles with your own. Both templates and the Team Section are part of Wingo UI Pro, $8 a month, $80 a year or $150 once ([pricing](https://wingo-ui.com/pricing)). A free account gets the free parts, like the Drawer and Search Input, if you would rather build the page yourself.

## Components in this post

- [Careers Page](https://wingo-ui.com/templates/careers-page) (Pro): A React careers page template: mission, team mosaic, values, benefits, filterable open roles with a job drawer and apply form. Install: `npx wingo-ui@latest add careers-page`
- [Job Detail Page](https://wingo-ui.com/templates/job-detail-page) (Pro): A React job posting page template: the role, benefits, interview steps, team, a sticky apply card, an application form and similar roles. Install: `npx wingo-ui@latest add job-detail-page`
- [Team Section](https://wingo-ui.com/components/team-section) (Pro): A React team section for an about page: portrait cards, tall portrait cards, a compact roster or a list with bios, plus a "We're hiring" tile. Install: `npx wingo-ui@latest add team-section`

## FAQ

### What should a careers page include?

A one-paragraph mission, the team, your values and benefits, how you hire, and the open roles grouped by department with location, type and salary range. Each role needs its own page with the full description and an apply form, plus an open application for people who do not see a fit.

### Can I add JobPosting structured data to the careers list page?

No. Google's job posting documentation says the markup must only be used on pages that contain a single job posting, and not on pages that list jobs. Put it on each role page, such as /careers/senior-frontend-engineer.

### How do I mark a remote job for Google for Jobs?

Set jobLocationType to TELECOMMUTE and list the countries applicants may live in under applicantLocationRequirements. Google only accepts TELECOMMUTE for fully remote roles, so a hybrid role keeps its office address in jobLocation.

### What happens to a job posting after the role is filled?

Remove it: return a 404 or 410 for the page, remove the markup, or set validThrough to a past date. Google warns that leaving expired jobs up can lead to a manual action.

### Is the Wingo UI careers page template free?

No. The Careers Page, the Job Detail Page, the Team Section and the File Upload they use for resumes are Pro items. Other parts they build on, like the Drawer, Search Input, Empty State and Marketing Section, are free.

---

Source: https://wingo-ui.com/blog/careers-page-template
