Skip to content

Comments

feat: full stack modernization — Docker, dependency upgrades, Express→Next.js migration#8

Merged
dpuscher merged 32 commits intoproductionfrom
modernize-dependencies
Feb 20, 2026
Merged

feat: full stack modernization — Docker, dependency upgrades, Express→Next.js migration#8
dpuscher merged 32 commits intoproductionfrom
modernize-dependencies

Conversation

@dpuscher
Copy link
Owner

@dpuscher dpuscher commented Feb 19, 2026

Summary

Full modernization of the stack: Docker setup, CI/CD, major dependency upgrades across the board, migration from a custom Express server to Next.js API routes, TypeScript adoption, and a round of bug fixes to stabilize the migrated app.


Docker & CI/CD

  • Add Docker setup, Dockerfile, docker-compose, and migrate static assets to public/
  • Add GitHub Actions workflows for staging and production: build/push Docker images to GHCR, trigger Dokploy redeploy via webhook
  • Fix Docker build failures with Node 22 (remove deprecated --production flag, drop redundant stage)
  • Add multi-platform builds (linux/amd64 + linux/arm64) for ARM64 Dokploy server
  • Make Last.fm auth callback URL dynamic (was hardcoded)

Dependency Upgrades

Package Before After
Next.js 9 14
React / react-dom 16 18
react-redux 7 9
next-redux-wrapper 4 8
styled-components 5 6
redux 4 5
redux-thunk 2 3
Mongoose 5 8
Redis client 2 5
connect-redis 4 9
Express 4 5
Helmet 3 8
ESLint 6 9
Jest 24 29
Stylelint 12 16
TypeScript added

Removed abandonware: sw-precache-webpack-plugin, react-lazy, object-dig, intersection-observer, redux-devtools-extension (replaced with @redux-devtools/extension).


Express → Next.js Migration

  • Migrate all Express route handlers to Next.js API routes (pages/api/)
  • Replace Redis-backed express-session + Passport with iron-session (encrypted cookie sessions)
  • Redis retained for Discogs/Last.fm API response caching
  • Remove server.js, config/express.js, config/passport.js, and all app/routes/ + app/middlewares/
  • Add Next.js middleware to redirect unauthenticated requests to /login
  • Convert detected and scrobbled pages to dynamic routes (pages/detected/[barcode].tsx, pages/scrobbled/[barcode].tsx)

TypeScript Migration

  • Rename all .js/.jsx files to .ts/.tsx across pages, components, lib, styles, config, and client
  • Add tsconfig.json with allowJs: true for incremental migration
  • Add types/styled-components.d.ts for css prop support
  • Replace propTypes/defaultProps with TypeScript interfaces across all 21 components and pages
  • Migrate to ESLint flat config format (eslint.config.js)

Bug Fixes

  • OverwriteModelError in dev mode — add mongoose.models.X || guard so models aren't re-registered on hot reload
  • Unauthenticated session handlingfetchSession now checks response.ok; Session component redirects to /login on error; middleware guards all authenticated pages
  • useWrappedStore props — pass full _app props (not just pageProps) so next-redux-wrapper v8 can reconstruct props.pageProps correctly
  • React 18 warnings — remove defaultProps from Logo/LogoSmall, use transient prop $visible in Camera, move viewport meta to _app.tsx
  • Broken history/query item linksProfileHistoryItem and QueryRelease had stale query-param href after the dynamic route migration; clicks produced 404s
  • Null crash on auto-scrobble deleteautoScrobbleReducer called .filter() on state.data (initially null) before data arrived
  • Silent data corruption on API errorshistoryActions, autoScrobbleActions, and releaseActions dispatched error response bodies as valid data (missing response.ok checks), causing .map is not a function crashes
  • Error objects serializing to {}history.ts and barcode/[id].ts used { err } in JSON responses; Error properties are non-enumerable
  • Discogs errors swallowedgetRelease called reject() with no argument; actual Discogs errors were invisible
  • Duplicate key race condition — React 18 StrictMode double-invokes componentDidMount, sending two concurrent requests for the same barcode before either saves; fixed by catching E11000 in firstOrCreate and retrying findOne
  • Class name typoProfileHistorysProfileHistory
  • Profile page SSR — migrate from deprecated getInitialProps to getServerSideProps with next-redux-wrapper

Test plan

  • Docker build succeeds for both amd64 and arm64
  • yarn dev starts without errors
  • Log in via Last.fm → session persists, profile page loads
  • Log out and visit /profile directly → redirects to /login
  • Scan a barcode → navigates to /detected/<barcode>, release info loads
  • Search for a release, click a result → navigates to /detected/id:<id> without 404 or duplicate key error
  • Scrobble a release → navigates to /scrobbled/<barcode>
  • History items on /profile are clickable and navigate correctly
  • Delete an auto-scrobble item → no crash

Add GitHub Actions workflows for staging and production branches that
build and push Docker images to GHCR, then trigger Dokploy redeployment
via webhook. Also ignore .mcp.json.
- Remove deprecated --production flag from yarn install (Yarn 4)
- Drop redundant prod-deps stage; reuse deps stage in runner
- Set NODE_OPTIONS=--openssl-legacy-provider for webpack 4 / OpenSSL 3 compatibility
Dokploy server runs on ARM64; add QEMU and Buildx setup steps and set
platforms: linux/amd64,linux/arm64 so the manifest covers both architectures.
- 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.
@dpuscher dpuscher force-pushed the modernize-dependencies branch from 777a0e0 to c0f0ef2 Compare February 19, 2026 22:11
@dpuscher dpuscher changed the title fix: post-migration bug fixes feat: full stack modernization — Docker, dependency upgrades, Express→Next.js migration Feb 19, 2026
@dpuscher dpuscher merged commit 31b82e4 into production Feb 20, 2026
@dpuscher dpuscher deleted the modernize-dependencies branch February 20, 2026 23:06
@dpuscher dpuscher restored the modernize-dependencies branch February 20, 2026 23:06
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