+ No resume upload and no application portal. Write us something real. A short, specific
+ message about what you have built beats three pages of adjectives every time.
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(company)/careers/page.tsx b/app/(company)/careers/page.tsx
new file mode 100644
index 0000000..3d0177a
--- /dev/null
+++ b/app/(company)/careers/page.tsx
@@ -0,0 +1,75 @@
+import Link from "next/link";
+
+import { ArrowRight, Globe, MapPin } from "lucide-react";
+
+import { BreadcrumbJsonLd } from "@/components/json-ld";
+
+import { roles } from "@/lib/careers/roles";
+import { buildMetadata } from "@/lib/seo/metadata";
+
+export const metadata = buildMetadata({
+ path: "/careers",
+ title: "Careers",
+ description:
+ "Open roles at Interview Resources. A small, remote, deliberately unglamorous team building evidence-graded interview research. Engineering and go-to-market.",
+});
+
+export default function CareersPage() {
+ return (
+
+
+
+
Careers
+
+ We build interview research that shows its sources. Every question in a report is graded by
+ how well it is grounded, and the parts we cannot back up say so. That standard is easy to
+ write down and hard to keep, which is most of what the work here is.
+
+
+ The team is small and remote. You will own whole surfaces rather than tickets, you will talk
+ to customers, and there is nobody between you and the thing you ship.
+
+
+
Open roles
+
+
+ {roles.map((role) => (
+
+
+
+
{role.title}
+
+
+
+ {role.tagline}
+
+
+
+
+ {role.location}
+
+
+
+ {role.type}
+
+
+
+
+ ))}
+
+
+
+
Nothing here fits?
+
+ Apply to the role closest to what you do and say so in your message. We would rather read
+ a good application to the wrong opening than miss you entirely.
+
+
+
+ );
+}
diff --git a/app/api/careers/route.ts b/app/api/careers/route.ts
new file mode 100644
index 0000000..88a2e9e
--- /dev/null
+++ b/app/api/careers/route.ts
@@ -0,0 +1,89 @@
+import { and, eq, gt } from "drizzle-orm";
+
+import { jobApplicationSchema } from "@/lib/careers/application";
+import { getRole } from "@/lib/careers/roles";
+import { db } from "@/lib/db/index";
+import { jobApplications } from "@/lib/db/schema";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * How long one email address has to wait before applying to the same role
+ * again. Public, unauthenticated, and writing to the database, so it needs
+ * some floor — but the floor is per role, because applying to two different
+ * openings in one sitting is a reasonable thing to do.
+ */
+const REAPPLY_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+/** Receives a careers application and files it. Deliberately unauthenticated. */
+export async function POST(request: Request) {
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: "Send JSON." }, { status: 400 });
+ }
+
+ const parsed = jobApplicationSchema.safeParse(payload);
+ if (!parsed.success) {
+ // Field-keyed so the form can put each message next to its input.
+ const fieldErrors: Record = {};
+ for (const issue of parsed.error.issues) {
+ const field = String(issue.path[0] ?? "form");
+ fieldErrors[field] ??= issue.message;
+ }
+ return Response.json(
+ { error: "Please fix the highlighted fields.", fieldErrors },
+ { status: 400 }
+ );
+ }
+
+ const { roleSlug, name, email, message, consent } = parsed.data;
+
+ // The schema already rejected unknown slugs; this is what gives us the title
+ // to snapshot alongside the row.
+ const role = getRole(roleSlug);
+ if (!role) {
+ return Response.json({ error: "That role is not open." }, { status: 400 });
+ }
+
+ const normalisedEmail = email.trim().toLowerCase();
+
+ try {
+ const [recent] = await db
+ .select({ id: jobApplications.id })
+ .from(jobApplications)
+ .where(
+ and(
+ eq(jobApplications.email, normalisedEmail),
+ eq(jobApplications.roleSlug, roleSlug),
+ gt(jobApplications.createdAt, new Date(Date.now() - REAPPLY_WINDOW_MS))
+ )
+ )
+ .limit(1);
+
+ // Not an error as far as the applicant is concerned: they already told us,
+ // and a second copy of the same message helps nobody. Report success.
+ if (recent) {
+ return Response.json({ ok: true, duplicate: true });
+ }
+
+ await db.insert(jobApplications).values({
+ roleSlug,
+ roleTitle: role.title,
+ name: name.trim(),
+ email: normalisedEmail,
+ message: message.trim(),
+ consent,
+ });
+ } catch (error) {
+ console.error("careers: could not file application", error);
+ return Response.json(
+ { error: "We could not file that. Please try again in a moment." },
+ { status: 500 }
+ );
+ }
+
+ return Response.json({ ok: true });
+}
diff --git a/components/footer.tsx b/components/footer.tsx
index fcb1180..8e36449 100644
--- a/components/footer.tsx
+++ b/components/footer.tsx
@@ -71,6 +71,11 @@ export function Footer() {
Blog
+
+ );
+}
+
+export function ApplicationForm({ roleSlug, roleTitle }: ApplicationFormProps) {
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [message, setMessage] = useState("");
+ const [consent, setConsent] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [isSending, setIsSending] = useState(false);
+ const [isSent, setIsSent] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ if (isSending) return;
+
+ // Validated with the same schema the route uses, so the browser and the
+ // server can never disagree about what a valid application looks like.
+ const parsed = jobApplicationSchema.safeParse({ roleSlug, name, email, message, consent });
+ if (!parsed.success) {
+ const next: FieldErrors = {};
+ for (const issue of parsed.error.issues) {
+ const field = String(issue.path[0] ?? "form") as keyof FieldErrors;
+ next[field] ??= issue.message;
+ }
+ setErrors(next);
+ return;
+ }
+
+ setErrors({});
+ setIsSending(true);
+ try {
+ const res = await fetch("/api/careers", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(parsed.data),
+ });
+ const body = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ fieldErrors?: FieldErrors;
+ };
+
+ if (!res.ok) {
+ setErrors({ ...body.fieldErrors, form: body.error ?? "Something went wrong." });
+ return;
+ }
+ setIsSent(true);
+ } catch {
+ setErrors({ form: "We could not reach the server. Please try again." });
+ } finally {
+ setIsSending(false);
+ }
+ }
+
+ if (isSent) {
+ return (
+
+
+
+ Applied. Your application is sent.
+
+
+ Thanks for applying to {roleTitle}. We read every application ourselves and we will get
+ back to you until then, use our application and tell us what you would change about it.
+ That is the most useful thing you can put in front of us.
+
+
+ }>
+ Try the product
+
+ }>
+
+ All open roles
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/lib/careers/application.ts b/lib/careers/application.ts
new file mode 100644
index 0000000..e991aee
--- /dev/null
+++ b/lib/careers/application.ts
@@ -0,0 +1,25 @@
+import { z } from "zod";
+
+import { roles } from "./roles";
+
+/**
+ * The application payload, shared by the client form and the API route so the
+ * two can never drift. The route is the authority: everything here is checked
+ * again server-side, because the browser copy is only a convenience.
+ */
+export const jobApplicationSchema = z.object({
+ roleSlug: z
+ .string()
+ .refine((slug) => roles.some((role) => role.slug === slug), "That role is not open."),
+ name: z.string().trim().min(2, "Tell us your name.").max(120, "That name is too long."),
+ email: z.email("That email address does not look right.").max(254),
+ message: z
+ .string()
+ .trim()
+ .min(30, "A few sentences, please. Thirty characters minimum.")
+ .max(5000, "Keep it under 5000 characters."),
+ /** The checkbox is the point of the field, so `true` is the only valid value. */
+ consent: z.literal(true, "We need your permission to store and read your application."),
+});
+
+export type JobApplicationInput = z.infer;
diff --git a/lib/careers/roles.ts b/lib/careers/roles.ts
new file mode 100644
index 0000000..6b22a59
--- /dev/null
+++ b/lib/careers/roles.ts
@@ -0,0 +1,166 @@
+/**
+ * The open roles, as data.
+ *
+ * Kept in code rather than the database on purpose: a job post is content that
+ * ships with a deploy and wants review in a pull request, and there are three
+ * of them. The database side of careers is only the inbound applications
+ * (`job_applications`), which is the part that actually grows.
+ *
+ * `slug` is the URL and the value stored on every application row, so treat it
+ * as permanent. Retitling a role is free; renaming its slug orphans the
+ * applications already filed under the old one.
+ */
+
+export interface Role {
+ slug: string;
+ title: string;
+ /** One line, used on the index card and as the meta description seed. */
+ tagline: string;
+ location: string;
+ type: string;
+ /** Rendered as a compact chip row above the JD. */
+ stack: string[];
+ /** Two or three paragraphs. What the role actually is. */
+ about: string[];
+ responsibilities: string[];
+ requirements: string[];
+ niceToHave: string[];
+ /** How we run the loop. Answers the question every candidate asks first. */
+ process: string[];
+}
+
+export const roles: Role[] = [
+ {
+ slug: "full-stack-ai-engineer-frontend",
+ title: "Full Stack AI Engineer (Front-end leaning)",
+ tagline:
+ "Own the surface people actually touch: the research flow, the report, and the interface that makes a model's output feel trustworthy.",
+ location: "Remote",
+ type: "Full time",
+ stack: ["TypeScript", "Next.js", "React", "Tailwind CSS", "AI SDK", "Postgres"],
+ about: [
+ "Interview Resources turns a company name into an evidence-graded interview report. Every question we show is labelled by how well it is grounded, and every claim carries the source it came from. That honesty is the product, and most of it is a front-end problem: a model can produce a confident paragraph in a second, and the interface has to make clear which parts of it are earned.",
+ "This role owns that surface. You will build the research form, the streaming run view, and the report itself, and you will go as deep into the backend as the feature needs. Front-end leaning means where you spend most of your week, not a wall you stop at. The work is close to the model, so you will spend real time on streaming, partial states, retries, and the long tail of ways a generative flow fails in front of a paying user.",
+ "We are small. You will pick the components, name the routes, and decide what a screen looks like. Nobody is going to hand you a Figma file for every ticket.",
+ ],
+ responsibilities: [
+ "Design and build product surfaces end to end in Next.js and React, from the route and the data fetch through to the last hover state.",
+ "Make streaming and long-running AI work legible: progress that means something, partial results that stay readable, and failures that explain themselves.",
+ "Turn a graded report into an interface a candidate can scan in two minutes and trust, including the parts where we say we do not know.",
+ "Build the server routes, database queries, and schema changes your features need, in Drizzle and Postgres.",
+ "Hold the line on accessibility, keyboard behaviour, dark mode, and mobile. These are requirements here, not a polish pass.",
+ "Write tests that survive refactors: Vitest for units and components, Playwright for the flows that take someone's money.",
+ "Watch what real users do after you ship, then fix what the data says is broken instead of what is fun to fix.",
+ ],
+ requirements: [
+ "Three or more years building production web applications in TypeScript, with a large share of it in React.",
+ "Real, current experience with a modern React framework and its server rendering model. We run Next.js App Router.",
+ "You can build a UI from a rough description and a product goal, without a pixel-level design to trace.",
+ "Comfort writing SQL and reasoning about a relational schema, not only calling an ORM and hoping.",
+ "You have shipped something that talks to an LLM API in production, and you know why the second version was different from the first.",
+ "Clear written communication. Most of what you decide here gets decided in writing.",
+ ],
+ niceToHave: [
+ "Strong visual instincts and a portfolio of interfaces you are proud of.",
+ "Experience with streaming responses, server-sent events, or the AI SDK.",
+ "Familiarity with Tailwind CSS v4 and modern component primitives.",
+ "You have worked at a company small enough that you also answered support tickets.",
+ ],
+ process: [
+ "Intro call, thirty minutes, with the founder.",
+ "A paid take-home you can finish in a focused afternoon, built on the real stack.",
+ "A ninety minute working session on your take-home: we extend it together.",
+ "A final conversation about scope, money, and what the first ninety days look like.",
+ ],
+ },
+ {
+ slug: "full-stack-ai-engineer-backend",
+ title: "Full Stack AI Engineer (Back-end leaning)",
+ tagline:
+ "Own the research pipeline: retrieval, grading, cost control, and the queue that keeps a multi-minute run from falling over.",
+ location: "Remote",
+ type: "Full time",
+ stack: ["TypeScript", "Node.js", "Postgres", "Drizzle", "AI SDK", "Vercel"],
+ about: [
+ "Behind every report is a pipeline that searches the open web, reads what it finds, extracts candidate questions, grades each one by the strength of its evidence, and throws away the rest. It runs for minutes, costs real money per run, and has to produce something defensible at the end or we refund the credit.",
+ "This role owns that pipeline. You will work on retrieval quality, prompt and schema design, the grading logic that decides whether a question is evidence-backed or merely inferred, the budget tracker that stops a run before it burns a customer's balance, and the job infrastructure that will move this work off the request path.",
+ "Back-end leaning means the centre of gravity, not the boundary. You will still open React files, because a pipeline change that nobody can see in the report is not finished.",
+ ],
+ responsibilities: [
+ "Own the research pipeline end to end: search, extraction, grading, caching, and the report artefact it produces.",
+ "Improve output quality with evaluations rather than vibes. Build the harness if we do not have the one you need.",
+ "Keep cost per run predictable: budget tracking, model selection, caching, and hard stops that fire before the money is gone.",
+ "Move long-running work onto a durable queue with retries, idempotency, and cancellation that actually cancels.",
+ "Design and migrate the Postgres schema, and keep migrations safe to run against production traffic.",
+ "Build and maintain the credits ledger, payment webhooks, and the reconciliation that catches it when they disagree.",
+ "Instrument everything. When a run degrades at two in the morning, the logs should already answer why.",
+ ],
+ requirements: [
+ "Four or more years building backend systems in TypeScript or another strongly typed language, including operating them in production.",
+ "Deep SQL and relational modelling: indexes, transactions, and what happens under concurrent writes.",
+ "You have built something on top of an LLM API that had to be correct, not just impressive in a demo, and you can explain how you measured it.",
+ "Practical experience with queues, retries, idempotency keys, and the failure modes of distributed work.",
+ "A habit of writing tests for the paths that touch money or data integrity.",
+ "Comfort in a codebase where you are also expected to change the front end when the feature needs it.",
+ ],
+ niceToHave: [
+ "Experience with retrieval, ranking, or search relevance.",
+ "You have run evaluation suites for a generative system and made a real quality call from the results.",
+ "Familiarity with Drizzle, serverless Postgres and connection pooling, or Vercel's runtime model.",
+ "Payments experience, especially the webhook and refund side.",
+ ],
+ process: [
+ "Intro call, thirty minutes, with the founder.",
+ "A paid take-home you can finish in a focused afternoon, built on the real stack.",
+ "A ninety minute systems conversation about the pipeline and how you would change it.",
+ "A final conversation about scope, money, and what the first ninety days look like.",
+ ],
+ },
+ {
+ slug: "gtm-lead",
+ title: "GTM Lead",
+ tagline:
+ "Own how people find us and why they pay: positioning, distribution, the funnel, and the numbers underneath all three.",
+ location: "Remote",
+ type: "Full time",
+ stack: ["Positioning", "SEO", "Lifecycle", "Analytics", "Content", "Partnerships"],
+ about: [
+ "We have a product that works and a story that is still too complicated. Candidates buy a report because they have a real interview in nine days and want to walk in prepared. That urgency is the whole go-to-market, and we have barely used it.",
+ "This role owns everything between a stranger and a paying customer. Positioning and messaging, the landing pages, the programmatic company pages that carry our organic search, lifecycle email, pricing experiments, partnerships with bootcamps and communities, and the analytics that tell us which of those was worth doing.",
+ "This is a builder's role, not a manager's. There is no team to inherit and no agency on retainer. You will write the copy, ship the page, run the experiment, and read the result yourself, and you will get an engineer's help whenever the change is genuinely technical.",
+ ],
+ responsibilities: [
+ "Own positioning and messaging, and keep the site, the emails, and the product's own copy telling the same story.",
+ "Build the acquisition engine: organic search, content, communities, partnerships, and paid where the maths supports it.",
+ "Own the funnel from first visit to first purchase to repeat purchase, and improve it with real experiments.",
+ "Design and run lifecycle campaigns around the moment that actually matters, which is an interview on a calendar.",
+ "Run pricing and packaging experiments, and defend the conclusions with numbers.",
+ "Build the reporting the whole company trusts: acquisition, activation, conversion, retention, and cost per acquisition.",
+ "Talk to customers every week and bring what you hear back into the roadmap.",
+ ],
+ requirements: [
+ "Three or more years in growth, product marketing, or go-to-market at a startup, with results you can describe in specifics.",
+ "You write well and quickly, and you can hold a technical audience without sounding like a brochure.",
+ "Working knowledge of SEO as it exists now, including how AI answer engines change what gets cited.",
+ "You are comfortable in analytics data and can define a metric, instrument it, and query it yourself.",
+ "Evidence that you have run an experiment, read an unflattering result, and changed course because of it.",
+ "You are happy owning outcomes without owning headcount.",
+ ],
+ niceToHave: [
+ "You have marketed to engineers, or to job seekers, or both.",
+ "Basic technical literacy: enough HTML, SQL, and command line to stop waiting on someone else.",
+ "Experience with programmatic or template-driven content at scale.",
+ "A network in the careers, bootcamp, or developer community space.",
+ ],
+ process: [
+ "Intro call, thirty minutes, with the founder.",
+ "A paid exercise: a written go-to-market teardown of our current funnel.",
+ "A ninety minute working session on that teardown, plus a conversation about the first two quarters.",
+ "A final conversation about scope, money, and what the first ninety days look like.",
+ ],
+ },
+];
+
+export function getRole(slug: string): Role | undefined {
+ return roles.find((role) => role.slug === slug);
+}
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 126d864..f0bd6bc 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -259,6 +259,35 @@ export const productEvents = pgTable(
]
);
+/**
+ * Careers applications. Deliberately standalone: an applicant is almost never
+ * a signed-in user, so there is no `users` reference and nothing here cascades
+ * from an account deletion.
+ */
+export const jobApplications = pgTable(
+ "job_applications",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ /** Matches a slug in lib/careers/roles.ts. Stored as text, not an enum, so
+ * closing a role never orphans the applications it already collected. */
+ roleSlug: text("role_slug").notNull(),
+ /** Snapshot of the title as advertised, so a later retitle cannot rewrite
+ * what someone believes they applied for. */
+ roleTitle: text("role_title").notNull(),
+ name: text("name").notNull(),
+ email: text("email").notNull(),
+ message: text("message").notNull(),
+ /** The "I give permission" checkbox. Only ever inserted as true — the
+ * column exists so the consent is on record, not to model a false state. */
+ consent: boolean("consent").notNull().default(false),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ },
+ (table) => [
+ index("job_applications_role_slug_created_at_idx").on(table.roleSlug, table.createdAt),
+ index("job_applications_email_idx").on(table.email),
+ ]
+);
+
export const researchCache = pgTable("research_cache", {
key: text("key").primaryKey(), // domain + interview_type
stage: text("stage").notNull(),
diff --git a/lib/seo/metadata.ts b/lib/seo/metadata.ts
index e404780..e8278ff 100644
--- a/lib/seo/metadata.ts
+++ b/lib/seo/metadata.ts
@@ -35,9 +35,16 @@ const ogImages = [
*/
type CompanyPagePath = `/interview-questions/${string}`;
+/**
+ * Role pages, for the same reason: the set is data in `lib/careers/roles.ts`
+ * and changes whenever a role opens or closes, so it cannot live in
+ * `publicRoutes`. `/careers` itself is a registered route.
+ */
+type CareersPagePath = `/careers/${string}`;
+
type BuildMetadataOptions = {
/** Must be a registered public route, so canonicals and the sitemap agree. */
- path: PublicRoute | CompanyPagePath;
+ path: PublicRoute | CompanyPagePath | CareersPagePath;
/** Fed through the root `%s · Interview Resources` title template. */
title: string;
description: string;
diff --git a/lib/seo/routes.ts b/lib/seo/routes.ts
index 6566060..a0fee39 100644
--- a/lib/seo/routes.ts
+++ b/lib/seo/routes.ts
@@ -65,6 +65,12 @@ export const publicRoutes = {
label: "Changelog",
lastModified: "2026-07-25",
},
+ "/careers": {
+ priority: 0.5,
+ changeFrequency: "monthly",
+ label: "Careers",
+ lastModified: "2026-09-01",
+ },
"/contact": {
priority: 0.5,
changeFrequency: "yearly",