A Next.js site presenting company news, job listings, and product/pricing
data scraped by the sibling company-intel-scraper
project. This repo is a read-only consumer of the PostgreSQL database that
scraper populates — it never writes to companies, news, jobs, products,
or job_locations.
Every page's primary, SEO-relevant content — the news/job/product lists,
filtered results, the /stats dashboard — is fetched directly from Postgres
inside an async Server Component and rendered server-side (SSR), with zero API
round trip. This matters because these are the pages search engines crawl and
users share links to; filters live in the URL (/jobs?country=India), so every
filtered view is a real server-rendered, indexable page. Everything else —
header ticker, footer "top news" picks, filter-dropdown option lists, live
dataset counts, infinite-scroll continuation pages — is not SEO-relevant,
changes independently of navigation, and would otherwise mean re-querying
Postgres on every request just to paint chrome. So those pieces are "use client" components that lazily fetch() a /api/* route after first paint.
Net effect: one Postgres round trip per page load for the content that matters
for crawlability, and cheap, cacheable client fetches for everything ancillary.
- Node.js 20+ (or Docker)
- An external PostgreSQL database, already created and populated by
company-intel-scraper(this app does not run migrations or seed data forcompanies/news/jobs/products/job_locations)
Copy .env.local.example to .env.local and fill in:
| Variable | Example / default | Used by | Description |
|---|---|---|---|
DATABASE_URL |
postgresql://user:pass@host:port/db_name |
lib/db.ts |
Postgres connection string for the pg.Pool. Same physical database the scraper writes to. |
PORT |
3000 |
Next.js server | Port the server listens on. |
SITE_URL |
http://localhost:3000 |
app/layout.tsx (metadata), app/robots.ts, app/sitemap.ts |
Public URL of the site, used to build absolute metadata/OG URLs and the sitemap/robots output. |
STATS_PASSWORD |
change-me |
app/stats/page.tsx, lib/statsAuth.ts |
Password gating the /stats analytics dashboard. If unset, isStatsAuthed() always returns false and the /stats login form is replaced with an "unconfigured" notice — the page can never be unlocked until this is set. |
Both npm run dev and npm run start load .env.local automatically (via
dotenv-cli); npm run build does not — see Docker below for
how the build stage gets around that.
git clone <repo-url> && cd company-intel
cp .env.local.example .env.local # fill in DATABASE_URL at minimum
npm install
npm run dev # development, hot reload — http://localhost:3000
# or
npm run build && npm start # production build + startExact scripts (package.json):
| Script | Command | Notes |
|---|---|---|
dev |
dotenv -e .env.local -- next dev |
Loads .env.local automatically |
build |
next build |
Does not load .env.local — env vars must already be in the process environment (or use the Docker path, which copies the file in before building) |
start |
dotenv -e .env.local -- next start |
Loads .env.local automatically |
lint |
eslint |
git clone <repo-url> && cd company-intel
cp .env.local.example .env.local # fill in DATABASE_URL at minimum
docker compose up --builddocker-compose.ymlbuilds from the localDockerfile, usesnetwork_mode: host(no published port mapping — the app binds directly to the host'sPORT), and loads env vars from.env.localviaenv_file.Dockerfileis a 4-stage multi-stage build (node:24-alpine):deps—npm installbuilder— copies source, then explicitlyCOPY .env.local .env.local(sincenpm run builddoesn't load it itself) and runsnpm run buildrunner— production image running as a non-rootnextjsuser, copying only.next/standalone+.next/static(enabled byoutput: "standalone"innext.config.ts),CMD ["node", "server.js"],EXPOSE 3000
Stop with docker compose down. If a rebuild seems to be reusing a stale
layer (e.g. after a dependency bump), force a fully clean image build with
no Docker layer cache: docker compose build --no-cache (see
Reloading & clearing caches for the full
picture across dev/prod/Docker).
| Route | Shows | Data-fetching model |
|---|---|---|
/ |
Hero, hero stat tiles, industry pills, latest 6 news items, latest 6 open roles | SSR: getNews({limit:6}) + getJobs({limit:6}) called directly in the Server Component. Stat tiles and industry pills are client components that separately fetch /api/meta. |
/news |
Filtered/searchable news list (field, company, source, topic, full-text query) | SSR for the first page of results: getNews(filters) built from searchParams. Filter dropdown options and the dataset-count sentence are lazy client fetches (/api/meta); scrolling past the first page lazy-fetches /api/news. |
/jobs |
Filtered/searchable job list (field, company, country, city, query) | SSR for the first page: getJobs(filters). Same client-side pattern as /news for filters, counts, and scroll-continuation. |
/products |
Filtered product/pricing list (field, company) | SSR for the first page: getProducts(filters). Same client-side pattern for filters/counts/continuation. |
/about |
Static description of the app, its data sources, and features | No database call — content is hardcoded. Its sidebar (AboutStats) is a client component that fetches /api/meta for live numbers/links/industries. |
/stats |
Password-gated analytics dashboard (see Analytics & /stats) |
SSR, once authenticated: getStats() called directly. Not indexed (robots: { index: false }, disallowed in robots.ts). |
app/robots.ts and app/sitemap.ts are generated, not static files:
robots.ts allows /, disallows /api/ and /stats; sitemap.ts lists
/, /news, /jobs, /products (all built off SITE_URL).
All routes call lib/queries.ts / lib/analytics.ts directly server-side.
None have auth beyond /stats and /api/track being disallowed for crawlers
in robots.ts's /api/ rule.
| Route | Method | Query params / body | Returns |
|---|---|---|---|
/api/news |
GET |
field, company, source, topic, q, limit, offset |
{ items: NewsItem[], total: number } |
/api/jobs |
GET |
field, company, country, city, q, limit, offset |
{ items: JobItem[], total: number } |
/api/products |
GET |
field, company, limit, offset |
{ items: ProductItem[], total: number } |
/api/meta |
GET |
— | Meta object: dataset counts, distinct fields/companies/sources/topics, countries with job counts, cities by country — powers useMeta() |
/api/ticker |
GET |
— | TickerItem[] — latest stock/market news for the header ticker |
/api/track |
POST |
Raw JSON analytics event | Always 204 No Content; failures are caught and logged server-side only, never surfaced to the client |
limit defaults to PAGE_SIZE (25) and is capped at MAX_LIMIT (200) inside
lib/queries.ts.
Rather than 14 flat files, components group into four roles:
Primary content (server-rendered, consumed by Server Component pages)
Cards.tsx—NewsCard/JobCard/ProductCard(+logoUrl(),fieldColor(),FieldTag). Pure, props-driven, no"use client"— safe to render on the server, used everywhere items are listed.Spinner.tsx— plain SVG loader, no data dependency.
Lazy client-side widgets (fetch their own data after first paint)
MarketTicker.tsx— scrolling ticker,fetch("/api/ticker")Footer.tsx— "Top News" / "Trending Articles" columns, directfetch("/api/news?...")calls (not viauseMeta)StatsTiles.tsx,IndustryChips.tsx,AboutStats.tsx,DatasetCount.tsx— all consume the shareduseMeta()hookInfiniteList.tsx— seeded with SSR'dinitialItems, then lazy-fetches/api/{news,jobs,products}for every subsequent page on scroll
Filter UI
FilterBar.tsx— collapsible filter sidebar; readsuseMeta()to populate dropdown options, writes filter state into the URL viarouter.push, persists open/closed state tolocalStorageSearchableSelect.tsx— generic searchable dropdown primitive used insideFilterBar, no data dependency of its own
Chrome / layout
NavLinks.tsx— desktop nav + mobile drawer, pure routing stateHeaderHeightSync.tsx— invisible; keeps a--header-hCSS var in sync viaResizeObserversince the ticker streams in after first paint
Analytics
Tracker.tsx— invisible; fires pageview/click events (see below)
lib/useMeta.ts is a "use client" hook that wraps fetch("/api/meta")
behind a module-level singleton cache with a 60-second TTL and in-flight
promise dedup. It exists because five separate client components
(StatsTiles, IndustryChips, AboutStats, DatasetCount, FilterBar) all
need the same Meta shape (dataset counts, distinct fields/companies/
sources/topics, country/city lists) — without it, each would independently
fire its own /api/meta request on every page that renders more than one of
them. useMeta() ensures they share a single in-flight fetch and a warm
cache; its lazy useState initializer reads the still-valid cache
synchronously, so client-side navigations within the TTL window render with
no loading flash. It declares its own local Meta type (a mirror of the one
in lib/queries.ts, not imported from it) specifically so client bundles
never pull in pg via lib/db.ts.
Tracking. Tracker.tsx is mounted once in the root layout. It has no
visual output — it fires one pageview event per route/filter change (every
route except /stats) and listens for clicks on any a[data-track] element
(the attribute Cards.tsx puts on every news/job/product card). Events are
sent to POST /api/track via navigator.sendBeacon, falling back to
fetch(..., { keepalive: true }). A visitor ID (crypto.randomUUID()) is
generated once and cached in localStorage["ci-visitor"]. /api/track
always returns 204, and swallows/logs any failure server-side — tracking is
explicitly designed to never break or slow down the site.
Storage. Events land in analytics_events, a table this app owns and
lazily creates itself (CREATE TABLE IF NOT EXISTS on server boot, see
Relationship to the scraper).
recordEvent() in lib/analytics.ts truncates each field to a fixed max
length before inserting and normalizes type to "click" or "pageview".
Dashboard. /stats shows (via getStats()): total page views, unique
visitors, and card clicks (all-time and last 7 days); a 14-day daily-pageviews
bar chart; views by page; most-visited categories; top 10 most-clicked news
titles, job titles, and companies.
Password protection. Access is gated by an httpOnly cookie
(stats-auth), not a header or session store:
- The page renders a login form bound to a
"use server"Server Action (login, defined inline inapp/stats/page.tsx). - On submit, it does a plain string comparison of the submitted password
against
process.env.STATS_PASSWORD. - On match, it sets the
stats-authcookie tostatsToken(password)— a SHA-256 hash of"company-intel-stats:" + password— withhttpOnly: true, sameSite: "lax", maxAge: 30 days, path: "/stats"— and redirects to/stats. On mismatch, it redirects to/stats?error=1. - On every request,
isStatsAuthed()independently recomputesstatsToken(process.env.STATS_PASSWORD)and compares it to the cookie value. This is stateless — there's no session table — so rotatingSTATS_PASSWORDimmediately invalidates every previously issued cookie. - If
STATS_PASSWORDis unset, authentication can never succeed (see the environment variables table above).
/stats is also excluded from indexing (metadata.robots = { index: false })
and disallowed in robots.ts.
When something looks stale after a code or data change, there are several independent cache layers between Postgres and your screen. Know which one you're actually fighting before reaching for a bigger hammer:
| Layer | Lives in | TTL / lifetime | Cleared by |
|---|---|---|---|
| Browser HTTP cache (JS/CSS bundles, images) | The browser | Until evicted | Hard reload |
useMeta() client cache (lib/useMeta.ts) |
Browser tab's JS memory | 60s | Any page reload (soft or hard — reloading JS always wipes it), or just waiting 60s |
Server in-memory caches: getMeta() (60s) and getTickerNews() (30s) in lib/queries.ts |
The Node process | 60s / 30s | Restarting the Node process (dev or prod) — there is no manual clear, only wait or restart |
Next.js build cache (.next/) |
Disk, in the repo | Until you delete it | rm -rf .next |
| Docker image layer cache (Docker path only) | Docker's local build cache | Until you delete it | docker compose build --no-cache |
- Normal refresh (Cmd+R / F5, or clicking a nav link): revalidates HTML/data but may still serve JS/CSS bundles from the browser's HTTP cache. Fine for seeing new data (news/jobs/products, stats).
- Hard reload (Cmd+Shift+R / Ctrl+Shift+R, or DevTools → Network →
"Disable cache" + reload): bypasses the browser's HTTP cache entirely.
Use this whenever you've changed code (components, styles, the
useMeta/Tracker/MarketTickerclient bundles) — in dev mode Turbopack hot-reloads most of this for you automatically, but after a full server restart or in a deployed environment, a normal refresh can still show you yesterday's JS.
Either kind of reload resets all client-side JS state, including the
useMeta() cache — there's no scenario where a page reload keeps a stale
Meta object around.
Dev, normal restart (keeps Turbopack's incremental cache — fast):
# stop with Ctrl+C, then
npm run devDev, no-cache restart (force a fully clean rebuild — use this if dev mode is behaving strangely, e.g. a stale route, a phantom type error, or the "Another next dev server is already running" lock error from a process that didn't shut down cleanly):
rm -rf .next
npm run devProduction, normal restart (reuses the build cache in .next/cache
for a faster rebuild):
npm run build && npm startProduction, no-cache restart (fully clean build from scratch — guarantees you're not looking at a stale artifact after a dependency bump or a confusing deploy):
rm -rf .next
npm run build && npm startDocker, normal rebuild (reuses Docker's layer cache — fast if only source files changed):
docker compose up --buildDocker, no-cache rebuild (ignores Docker's layer cache entirely —
use this after a package.json/Dockerfile change, or any time you
suspect a stale layer, e.g. npm install not picking up a version bump):
docker compose build --no-cache
docker compose upEither way, restarting the Node process is the only way to force-clear
getMeta()/getTickerNews()'s server-side caches immediately — they
otherwise self-expire after 60s/30s on their own, so if you just changed
data in Postgres (ran the scraper) and want to see it site-wide right
away without restarting, waiting under a minute also works.
Every list page (/news, /jobs, /products) is dynamic = "force-dynamic" with a loading.tsx boundary, and the client router's
staleTimes.dynamic is 0 (Next.js 15+ default, not overridden in
next.config.ts) — so dynamic pages are never served from a stale
client-side router cache. If a page ever appears to not update after a
client-side navigation, it's virtually never this cache; check the
server-side caches above first.
This app and company-intel-scraper connect to
the same physical Postgres database, each via its own DATABASE_URL. There
is no direct code or network coupling between the two repos — the only
coupling is the shared schema.
- The scraper owns and creates
companies,news,jobs, andproducts(itsdb.py); a separate script in that repo,enrich_locations.py, buildsjob_locations, which this app'sgetJobs()/getMeta()read for country/city filtering. - This app only ever runs
SELECTstatements against those five tables — it never writes to them. - This app additionally owns
analytics_events, which it creates itself (lib/analytics.ts,CREATE TABLE IF NOT EXISTS, run once per server boot). The scraper has no knowledge of this table; it is exclusively read/written by this app, backing only/statsand/api/track.