Skip to content

feat: modernize full stack — Next.js 14, React 18, TypeScript, remove Express - #10

Merged
dpuscher merged 27 commits into
stagingfrom
modernize-dependencies
Feb 23, 2026
Merged

feat: modernize full stack — Next.js 14, React 18, TypeScript, remove Express#10
dpuscher merged 27 commits into
stagingfrom
modernize-dependencies

Conversation

@dpuscher

@dpuscher dpuscher commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Summary

This branch performs a comprehensive modernization of the code-scrobble stack, bringing all major dependencies up to date, migrating away from the legacy Express server architecture, and converting the entire codebase to TypeScript.

Dependency upgrades

  • Next.js 9 → 14 (App-Router compatible pages, Turbopack-ready config)
  • React / React DOM 16 → 18 (concurrent features, no ReactDOM.render warnings)
  • Mongoose 5 → 8 (native promise API, built-in TypeScript types)
  • Redis v2 → v5 + connect-redis v4 → v9 (modern createClient API)
  • Express v4 → v5 (then fully removed, see below)
  • Dev tooling: ESLint 9 (flat config), Jest 29, Stylelint 16, TypeScript

Architecture: Express → Next.js API routes

  • Removed Express server (server.js, config/express.js, config/passport.js), all middleware files, and all app/routes/ handlers
  • Migrated every route to Next.js API routes under pages/api/
  • Authentication (Last.fm OAuth) rewritten to use iron-session + a middleware.ts auth guard
  • Session handling via lib/session.ts, DB connection via lib/mongodb.ts

Full TypeScript migration

  • Converted every .js / .jsx source file to .ts / .tsx
  • Replaced propTypes / defaultProps with TypeScript interfaces
  • Converted app/ backend modules (cache, discogs, lastfm, models) to TypeScript with proper Mongoose v8 type patterns and named ES module exports
  • Updated all consumers from require() to import

Bug fixes

  • Fix barcode lookup returning {} — caused by barcode format mismatch between scanned value (731452101716) and Discogs-stored value (7 3145210171 6); firstOrCreate now recovers from 11000 duplicate-key errors by querying on id rather than the mismatched barcode
  • Fix cache storing full Discogs results array instead of the release ID
  • Guard convertTimecode against null/undefined track duration
  • Add getMaster fallback in getRelease for Discogs master release IDs (404)
  • Fix updateFromDiscogs unconditionally overwriting existing barcode
  • Fix shouldFetchRelease guard against undefined release.data
  • Fix duplicate-key race condition in Release.firstOrCreate
  • Fix React 18 warnings, unauthenticated session state, OverwriteModelError in dev mode
  • Fix dynamic routes for /detected/[barcode] and /scrobbled/[barcode]

Test plan

  • Log in via Last.fm OAuth and verify session persists across page loads
  • Scan a barcode and confirm the release is detected and displayed correctly
  • Scrobble a release and verify it appears in Last.fm listening history
  • Visit /profile and confirm scrobble history and auto-scrobble list load
  • Verify /login, /legal, and /privacy are accessible without authentication
  • Confirm Docker build succeeds (docker compose up --build)
  • Run test suite (yarn test) — all tests green

- ESLint 6→9: migrate to flat config format (eslint.config.js), replace
  babel-eslint with @babel/eslint-parser, add @eslint/eslintrc FlatCompat
- Jest 24→29: add jest-environment-jsdom, update transform pattern for ts/tsx
- Stylelint 12→16: drop abandoned styled-components processor, new config file
- Add TypeScript: typescript, @types/node, @types/react, @types/react-dom,
  tsconfig.json with allowJs:true for incremental migration, next-env.d.ts
- Remove babel-plugin-transform-class-properties from .babelrc (in next/babel)
- Remove sw-precache-webpack-plugin: strip webpack plugin from next.config.js
  and the entry point manipulation for offline/polyfills; service worker support
  can be re-added later with next-pwa
- Remove react-lazy: replace styled(Lazy) with styled.div in QueryRelease.styles.js
  since IntersectionObserver is now native in all modern browsers
- Remove object-dig: replace dig() calls with optional chaining (?.) in
  config/passport.js and app/discogs.js
- Remove intersection-observer polyfill from lib/polyfills.js (native now)
- Remove redux-devtools-extension: replace with @redux-devtools/extension
  (drop-in replacement); update import in client/reduxStore.js

Updated safe packages (no API changes):
- dotenv 8→16, compression latest, cookie-parser latest, disconnect 1.2.2,
  lodash latest, morgan latest, prop-types latest, react-icons 3→4,
  react-timeago 4→8, styled-icons 9→10
Redis v4+ rewrote the API to be promise-based; v5 follows the same interface:
- createClient() no longer auto-connects; must call client.connect() explicitly
- All methods return Promises natively (no more util.promisify needed)
- client.set() now accepts an options object ({EX: ttl}) instead of
  positional 'EX', ttl arguments

Changes:
- app/cache.js: rewrite to use promise-based API directly
- config/express.js: update RedisStore initialization for connect-redis v9
  (named export, new RedisStore({ client }) instead of factory function)
- app/__mocks__/redis.js: update manual mock to match v5 promise-based API
- app/spec/cache.spec.js: update assertions for new set() options format
Mongoose 6 removed deprecated connection options; Mongoose 7 removed callbacks;
Mongoose 8 finalizes async-first API.

- server.js: remove deprecated useNewUrlParser/useUnifiedTopology options and
  mongoose.set('useCreateIndex', true) — all are now defaults
- config/passport.js: convert deserializeUser and strategy callback from
  callback-based to async/await (callbacks removed in Mongoose 7)
- app/routes/api/scrobble.js: convert Release.findOne callback to async/await
- app/routes/api/user/history.js: convert Release.find callback to async/await
- app/routes/api/user/autoscrobbles.js: convert Release.find callback to
  async/await; replace deprecated Model.update() with Model.updateOne()
Express 5 (released 2024) adds native async error propagation and stricter
route path parsing (wildcards require explicit patterns).

- express 4→5, express-session 1.17→1.19, helmet 3→8
- Remove body-parser package: use built-in express.json() instead
- config/express.js: replace bodyParser.json() with express.json(),
  update Helmet to disable CSP for now (to be configured at Next.js layer),
  Helmet v8 has stricter defaults and a new API for some options
- server.js: replace '*' wildcard (invalid in Express 5) with /(.*)/ regex
- react/react-dom 16→18, react-test-renderer 16→18
- react-redux 7→9
- next-redux-wrapper 4→8: convert _app.js from class component HOC to
  function component using useWrappedStore hook; export wrapper from
  client/reduxStore.js via createWrapper()
- styled-components 5→6: add babel-plugin-styled-components as explicit
  devDependency (no longer bundled in v6)
- redux 4→5, redux-thunk 2→3: named export (thunk vs default export);
  update import in reduxStore.js and all test files
- Remove enzyme + enzyme-adapter-react-16 (not compatible with React 18);
  replace with @testing-library/react; clean up setupTests.js
- Add @testing-library/dom peer dependency
Next.js 14 uses webpack 5 natively (supports OpenSSL 3), React 18, and
the Pages Router (stable). Key changes:

- Remove NODE_OPTIONS=--openssl-legacy-provider from Dockerfile and
  docker-compose.yml (no longer needed with webpack 5 + OpenSSL 3)
- next.config.js: add reactStrictMode:true, remove legacy options, add
  JSDoc type annotation for IntelliSense
- next/link API changes (Next.js 13+): <Link> now renders as <a> directly;
  add legacyBehavior prop where child is a styled.a or styled.button to
  prevent nested <a> elements; remove redundant inner <a> in CircleLayout
- _document.js: add null check for ctx.req (may be absent in static pages)
- @next/bundle-analyzer updated to v14 to match Next.js version
- Add @babel/runtime-corejs2 devDep (jest-fetch-mock compiled with it;
  previously provided transitively by next@9)
Rename all .js/.jsx files to .ts/.tsx across pages, components, lib,
styles, config, and client. Add TypeScript type annotations to resolve
compiler errors: generic props for styled-components, React.Component<any,any>
for class components, (Class as any).propTypes for external static
assignments, and module augmentation for styled-components css prop.

- Add types/styled-components.d.ts for css prop support
- Update tsconfig.json to exclude spec files (handled by babel-jest)
- Add @types/jest for test infrastructure types
- Fix styled-icons import paths (named exports from package root)
- Make Spinner size prop optional to support styled extension without size
Replace the custom Express server routing layer with Next.js API routes.
Add iron-session for encrypted-cookie session management (replaces
Redis-backed express-session + Passport). Redis is retained for
Discogs/Last.fm API response caching.

New API routes mirror the old Express routes:
- pages/api/session.ts
- pages/api/barcode/[id].ts
- pages/api/search/[query].ts
- pages/api/scrobble.ts
- pages/api/user/history.ts
- pages/api/user/autoscrobbles.ts
- pages/api/auth/lastfm.ts (redirect to Last.fm OAuth)
- pages/api/auth/callback/lastfm.ts (exchange token, create session)
- pages/api/auth/logout.ts (destroy session)

Infrastructure helpers: lib/session.ts, lib/mongodb.ts, lib/withAuth.ts

Update /auth/lastfm and /logout links to /api/auth/lastfm and
/api/auth/logout. Update profile page SSR to read session via iron-session.
Delete server.js, config/express.js, config/passport.js, all Express
route handlers (app/routes/), and Express middleware (app/middlewares/).

Remove dependencies: express, express-session, compression, morgan,
cookie-parser, passport, passport-lastfm, helmet, connect-redis.

Update package.json scripts to use next dev / next start.
Update Dockerfile CMD to node_modules/.bin/next start.

The app now runs entirely on Next.js: API routes handle all server-side
logic, iron-session handles auth, Redis handles Discogs/LastFM caching,
and mongoose models remain in app/models/.
- Remove defaultProps from Logo and LogoSmall (use JS default params)
- Use transient prop $visible in Camera styled component to prevent DOM forwarding
- Move viewport meta tag from _document.tsx to _app.tsx (Next.js 14 requirement)
Use mongoose.models.User/Release guard so models aren't re-registered
on hot module replacement, which was causing 500 errors on /api/session.
- fetchSession now checks response.ok and dispatches setErrorState on
  non-2xx responses instead of dispatching the error JSON as session data
- Session component redirects to /login when error state is set
- Add Next.js middleware to redirect cookie-less requests to /login
  before the page renders, matching the old Express auth guard behavior
The old Express server rewrote /detected/:barcode and /scrobbled/:barcode
to ?barcode= query params. Without it, direct navigation 404s. Convert to
proper Next.js dynamic routes pages/detected/[barcode].tsx and
pages/scrobbled/[barcode].tsx and update Router.push calls accordingly.
next-redux-wrapper v8's useWrappedStore expects the full _app props
({ pageProps, router, ... }) so it can reconstruct props.pageProps.
Passing only pageProps caused props.pageProps to be undefined, silently
dropping all getInitialProps results (e.g. barcode in detected/[barcode]).
Remove all prop-types usage across 21 components and pages. Replace
with named TypeScript interfaces (ComponentNameProps pattern) and
default values in parameter destructuring. Remove prop-types package.
…rops

Replace deprecated getInitialProps with getServerSideProps using
next-redux-wrapper for proper server-side Redux store hydration.
The detected page was converted from query-param routing (pages/detected.tsx)
to a dynamic route (pages/detected/[barcode].tsx). Update the Link href to
match the new route structure so history item clicks don't 404.
Initial state has data: null. REMOVE_AUTO_SCROBBLE could be dispatched
before RECEIVED_AUTO_SCROBBLES arrives, causing null.filter() to throw.
…e actions

4xx/5xx responses resolve successfully with error bodies, which were being
dispatched as valid data. This caused .map() to be called on an error object,
crashing the app. Now throws on non-ok responses so the error handler runs.
Error objects have non-enumerable properties; JSON.stringify({err}) produces {}.
Use err.message string instead, consistent with other routes using { error: '...' }.
Typo caused misleading names in React DevTools and stack traces.
Same issue as ProfileHistoryItem — the detected page was converted to a
dynamic route but the old query-param href was left in QueryRelease.
- Pass actual Discogs error to reject() instead of calling reject() with no
  argument, so the error propagates correctly through the promise chain
- Fix error serialization in barcode API handler (same issue as history.ts)
- Add response.ok check in releaseActions before parsing JSON
React 18 StrictMode double-invokes componentDidMount, causing two concurrent
requests for the same barcode. Both findOne calls return null before either
saves, leading to two createFromDiscogs calls and an E11000 duplicate key error.

Catch the duplicate key error (code 11000) and retry the findOne to return
the record that was created by the winning request.
Accessing release.data.id when data is absent (e.g. during loading state)
caused a TypeError on page refresh. Optional chaining prevents the crash.
Convert all five source files in app/ to TypeScript with proper types
and ES module syntax, replacing CommonJS require/module.exports:

- app/cache.ts: named exports with generic Cache.get<T>()
- app/discogs.ts: interfaces for Discogs API shapes (ReleaseData,
  SearchResult, DiscogsData); typed disconnect client
- app/lastfm.ts: exports LastFMUserData interface for consumers
- app/models/release.ts: Mongoose v8 pattern with IRelease,
  IReleaseMethods, IReleaseModel; default export
- app/models/user.ts: same Mongoose pattern with UserJSON interface
  exported for session typing

Update all consumers (pages/api/*, lib/withAuth.ts, app/spec/) to use
ES import statements instead of require().

Also includes bug fixes incorporated during the conversion:
- Fix barcode cache storing full results array instead of release ID
- Fix firstOrCreate 11000 error recovery querying by id instead of
  barcode param, resolving {} response caused by barcode format
  mismatch between scanned value and Discogs-formatted stored value
- Fix updateFromDiscogs unconditionally overwriting existing barcode
- Guard convertTimecode against null/undefined track duration
- Add getMaster fallback in getRelease for master release IDs (404)
- Add legal/privacy routes to middleware public paths
@dpuscher
dpuscher merged commit f66854d into staging Feb 23, 2026
@dpuscher
dpuscher deleted the modernize-dependencies branch February 23, 2026 20:57
@github-actions github-actions Bot mentioned this pull request Feb 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant