Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. Careers Page Template in React: Apply Form and JobPosting
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. Careers Page Template in React: Apply Form and JobPosting
Build it: screens and apps
Build it: screens and apps

Careers Page Template in React: Apply Form and JobPosting

SR
Serban Rusu · Founder of Wingo UI
Nov 6, 2026 · 11 min read

On this page

0%
  1. What should a careers page template include?
  2. How do you install the careers page template?
  3. How do you replace the sample content?
  4. Why does every role need its own page?
  5. How do you add JobPosting structured data for Google jobs?
  6. Where should the applications go?
  7. How does the careers page behave on a phone?
  8. Should the team section live on the careers page or the about page?
  9. What are the common careers page mistakes?
  10. Next step
  11. FAQ
    1. What should a careers page include?
    2. Can I add JobPosting structured data to the careers list page?
    3. How do I mark a remote job for Google for Jobs?
    4. What happens to a job posting after the role is filled?
    5. Is the Wingo UI careers page template free?

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.

Published Nov 6, 2026

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, 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:

SectionThe question it answersIn the template
HeroWhy does this company exist, in one paragraph?labels.heroTitle, labels.heroDescription
NumbersHow big and how old is it?stats, showStats
TeamWho would I work with?team, moments, showTeam
Values and benefitsHow do people work here, and what do I get?values, perks
Hiring processWhat happens after I apply, and how long does it take?steps, showProcess
Open rolesIs there a role for me, where, and for how much?roles, departments, groupByDepartment
Open applicationWhat 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 (opens in a new tab) (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 (opens in a new tab) (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 for the job panel, the free Search Input for the role filter, the free Empty State for "No roles match those filters", the File Upload for the resume and the Team Section. With no props, the page renders nine sample roles of a made-up company called Acme.

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
$ npx wingo-ui@latest add careers-page
ProCareers Page docs

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 (opens in a new tab) (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 explains the < escape and how to keep markup in sync with the 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
$ npx wingo-ui@latest add job-detail-page
ProJob Detail Page docs

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 (opens in a new tab) 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 (opens in a new tab) (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 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 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"
/>
)
}
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
$ npx wingo-ui@latest add team-section
ProTeam Section docs

Set hiring={false} when nothing is open, so the tile does not invite people to an empty list. Our SaaS website template guide covers how careers fits with the other pages of a company site, and the build category 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 and the 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). 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

    A React careers page template: mission, team mosaic, values, benefits, filterable open roles with a job drawer and apply form.

    Pro
  • Job Detail Page

    A React job posting page template: the role, benefits, interview steps, team, a sticky apply card, an application form and similar roles.

    Pro
  • Team Section

    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.

    Pro

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.

Try it in your project

Careers Page

Pro

A React careers page template: mission, team mosaic, values, benefits, filterable open roles with a job drawer and apply form.

75 components free, no account needed. Pro from $8 a month.

Open Careers Page liveSee pricing
  • Careers
  • Templates
  • Next.js
  • Structured data

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Build it: screens and apps

SaaS Website Template: The 7 Pages Beyond the Home Page

A SaaS website template needs more than a home page. What pricing, about, careers, changelog, contact, legal and compare pages must hold, built in Next.js.

SRSerban Rusu·Nov 5, 2026·20 min read
Build it: screens and apps

AI SaaS Landing Page Template: The 4 Sections It Needs

An AI SaaS landing page template needs four sections a generic SaaS page skips: a demo that answers, a data training policy, usage pricing and named models.

SRSerban Rusu·Nov 6, 2026·9 min read
Build it: screens and apps

API Key Management UI: Create, Show Once, Roll, Revoke

Build an API key management UI in React: create keys with scopes and expiry, show the secret once, copy, roll and revoke, and store only a hash on the server.

SRSerban Rusu·Nov 6, 2026·10 min read
Newsletter

Get new posts by email

New posts, product news and occasional offers from Wingo UI. We count clicks to keep it relevant.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • For teams
  • Theme generator

Resources

  • Docs
  • Contact
  • Programs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Compare

  • Wingo UI vs paid kits
  • Wingo UI vs shadcn/ui
  • shadcn/ui alternatives
  • MUI alternatives
  • Tailwind Plus alternatives
  • Magic UI alternatives
  • Free components

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI