Connected Suite · System Design
Three apps, one typed data contract, built so the hard parts of life manage themselves.
A shared Bearer-token contract lets three independent Next.js apps pass job, housing, and financial data across each other server-side, so your life stays in sync without you re-entering anything.
Landing a job, moving to a new city, getting your finances in order: these don't happen in isolation, but every tool for them assumes they do. So you carry the coordination by hand. You copy your salary into a budgeting sheet. You look up what rent in a new city costs against that number. You manually flag which apartments are realistic given the offer. The tools stay separate. You do the glue work.
The principle I built toward: done doesn't require you. A job signed in one app should ripple into the others automatically, with nothing in between except the contract.
I wrote the data contract before building any UI. Each app was spec'd as a producer, consumer, or both against that contract. The integration layer is HTTP, not a shared database — which means each app is fully independent and could be replaced without touching the others.
GET /api/external/jobs. Returns typed Job[] with salary, location, and status.
GET /api/external/listings. Returns typed Listing[] with rent, deposit, and status.
All three apps share a single CROSS_APP_SECRET environment variable. Every cross-app fetch puts it in the Authorization header as a Bearer token. Every producer validates it before returning any data. The check happens inside the Next.js route handler — the browser never sees the secret and it never touches the client bundle.
export async function GET(req: Request) {
const auth = req.headers.get('authorization');
const secret = process.env.CROSS_APP_SECRET;
if (!secret || auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// ... return jobs
}
async function getJSON<T>(url: string | undefined, key: string): Promise<T[]> {
const secret = process.env.CROSS_APP_SECRET;
if (!url || !secret) return [];
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secret}` },
next: { revalidate: 300 }, // 5-min server-side cache
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data?.[key]) ? (data[key] as T[]) : [];
}
The next: { revalidate: 300 } call means Prosper caches the cross-app responses for 5 minutes at the server layer — fast enough to feel instant, not so aggressive that a new job or listing is stale for long. Graceful degradation is also explicit: any network failure or bad status returns an empty array, so the Planning page degrades cleanly rather than throwing.
The contract lives as a CONTRACT.md in each repo — identical copies, intentionally. The canonical source is in Godspeed. Each object type uses a namespace prefix on its ID (gs_ for jobs, pv_ for listings) so Prosper can tell provenance at a glance with no ambiguity.
Consumed by Provision (affordability) and Prosper (income projection)
interface Job {
id: string; // "gs_123" — gs_ prefix
company: string;
title: string;
city: string | null;
state: string | null;
remote: boolean;
salary_min: number | null;
salary_max: number | null;
salary_estimated?: boolean; // true when Godspeed
// estimates salary vs. listed
status: "new" | "applied"
| "interviewing"
| "offer" | "rejected";
source: string | null;
url: string | null;
updated_at: string; // ISO 8601
}
Consumed by Prosper (upcoming housing costs and cash-flow projection)
interface Listing {
id: string; // "pv_456" — pv_ prefix
name: string;
city: string;
state: string;
rent: number;
deposit: number | null;
app_fee: number | null;
beds: number | null;
baths: number | null;
sqft: number | null;
lease_term_months: number | null;
concessions: string | null;
status: "saved" | "toured"
| "applied" | "approved"
| "signed" | "rejected";
updated_at: string; // ISO 8601
}
Provision's listings page fetches Godspeed jobs on load, then calls pickPrimaryJob() to select the most relevant one — offer status first, then interviewing, then most recently updated. That job's salary_min drives the affordability classification against each listing's rent.
export function classifyAffordability(
rent: number | null,
salaryMin: number | null,
): "affordable" | "stretch" | null {
if (!rent || !salaryMin) return null;
const monthlyGross = salaryMin / 12;
return rent <= monthlyGross * 0.3 ? "affordable" : "stretch";
}
The result is a badge on every listing card — no manual calculation required. When no Godspeed data is available (user hasn't connected or Godspeed is unreachable), the function returns null and the badge is omitted rather than showing a wrong value.
Job-Search Dashboard · Next.js + Supabase
The longer you use it, the richer your experience bank gets — making every future resume and cover letter faster and more targeted than the last.
Full case study →Moving Command Center · Next.js + Firebase
A move normally lives across browser tabs and a dying spreadsheet. Provision makes it one organized place you can hand off to your future self the next time you move.
Full case study →Personal Finance Dashboard · Next.js + Supabase + Plaid
Your full financial picture updates automatically every day. Over time Sparks builds a learning history tied to your real numbers, so advice gets more relevant the longer you use it.
Full case study →Each app has its own database. Integration happens at the API layer, not the data layer. This means each app is independently deployable and the integration contract is explicit and versioned rather than implicit table access. The tradeoff is latency on cross-app reads — which is why Prosper adds a 5-minute server cache.
Consumers only read. No app can write to another app's data. This prevents the class of bugs where a downstream app corrupts an upstream source of truth. The cost is that there's no real-time push — consumers pull when they need data, not when producers update.
Every cross-app fetch returns an empty array on any error — network failure, wrong status, missing env var. Pages that consume cross-app data are built to render without it. A recruiter using Prosper's Planning page never sees a broken state just because Godspeed is unreachable.
Prosper's Job interface includes salary_estimated?: boolean as an optional field. Provision's ContractJob interface doesn't define it — so Provision silently ignores the field even though Godspeed sends it. This doesn't break anything (Provision doesn't use that field), but it's a contract drift that a stricter shared-types package would prevent. In a team environment I'd extract the interfaces into a published internal package both consumers import from, so a producer changing its schema breaks the consumer's build before it breaks production.
The three apps are in real use. Two Vercel cron jobs keep Godspeed's data fresh on a schedule. A third keeps Prosper's Plaid balances current. Cross-app data moves at request time with a 5-minute cache. Manual re-entry is gone — a job change in Godspeed changes what's affordable in Provision and what the income projection shows in Prosper, automatically.