Skip to content

Modernize the API and web app - #1862

Merged
lifeiscontent merged 21 commits into
mainfrom
modernize-web
Oct 7, 2026
Merged

lifeiscontent merged 21 commits into
mainfrom
modernize-web

Conversation

@lifeiscontent

Copy link
Copy Markdown
Owner

Closes #1861.

The API moves to Ruby 4, Rails 8.1, and Postgres 18. The GraphQL schema now maps one to one to the RealWorld REST API. Devise is replaced with has_secure_password, profiles merge into users, and lists batch their queries with the dataloader.

The web app replaces Next.js with Vite and React Router 8 in SPA mode, with Apollo Client 4 behind route loaders and actions, and Storybook 10. It follows the RealWorld frontend spec and runs the shared RealWorld e2e suite in CI.

Notes for review:

  • The migrations drop the profiles and Active Storage tables after they copy bio and image to users.
  • The JWT signing key changes, so existing sessions end.
  • The vendored e2e suite has two fixes that I will also send upstream.

Each project now has a mise.toml: api pins Ruby and Postgres, and web
pins Node. These replace the .tool-versions and .ruby-version files.
setup-ruby reads api/mise.toml, and the web workflow installs Node with
mise-action.

Node goes from 18.17 to 24.21. Node 18 is no longer supported, and
Storybook 10 needs Node 20.19 or later.
Storybook 10 removes the essentials addons and moves the test and
preview-api packages into storybook. The addon now takes a createClient
function, so preview.ts makes the client with the app cache.

Stories now pass fn() spies in args, because actions from argTypes are
no longer spies. main.ts no longer imports lodash, because Storybook 10
loads it as ESM. typescript is a dev dependency, because the import
lint rule reads the storybook/test types.
The article lists used Relay cursor connections, so a client could only
go to the next or previous page. The RealWorld frontend spec shows
numbered pages (/?page=2). The articles, feed, user articles, and
favorite articles fields now take limit and offset arguments, the same
as the RealWorld API, and return the articles of the page with the
total count.

The *Connection fields are removed. The web app is their only client
and changes in the same branch.
The article inputs took the IDs of existing tags, so a user could not
add a new tag. The RealWorld API takes tag names and creates the tags
that do not exist. createArticle and updateArticle now take tagList
with the same behavior. Blank and duplicate names are ignored, and an
empty list removes all tags.
Update Ruby in the Gemfile and mise.toml, and update all gems with
bundle update. Apply the bin/rails app:update changes that the app
needs, and keep the app configuration such as CORS, port 4000, Spring
and the evented file watcher.

Set config.load_defaults to 8.1 and remove the obsolete Rails 6.1
framework defaults file. Add the Active Storage migration that
app:update copied. Disable the image variant processor because the app
does not attach files.

Use the generic arm64-darwin platform in Gemfile.lock and replace the
deprecated mingw platforms with windows.
The JWT code read secret_key_base from the credentials file. When the
master key is not available, that value is nil, so tokens were signed
with an empty key in development and test. Rails.application.secret_key_base
reads SECRET_KEY_BASE, then the credentials, and uses a local secret in
development and test.
Set Postgres to 18.6 in mise.toml and use the postgres:18 service image
in CI. Rails 8.1 and pg 1.7 support Postgres 18.

Pin actions/checkout and ruby/setup-ruby to commit SHAs with a version
comment, as the web workflow does. Remove the libpq-dev install step
because the pg gem now ships a precompiled build for x86_64-linux.
Add the empty lines that the layout cops require and remove a redundant
constant base. Give the two boolean GraphQL resolvers a question mark
name with resolver_method, so the schema does not change. Allow bang
methods such as User#authenticate! to return a boolean.
popularTags returned only the 20 most used tags, so a new tag did not
show in the sidebar. The RealWorld frontend spec shows the list of all
tags. popularTags now returns every tag that has articles, most used
first and then by name, so the order is stable.
Each REST endpoint of the RealWorld API now has one GraphQL field with
the same name, arguments, and data. For example, GET /api/articles is
articles(tag, author, favorited, limit, offset), which returns articles
and articlesCount. The types have the fields of the REST JSON: User has
email, token, username, bio, and image, Profile has following, and
Article has tagList, favorited, and author.

Errors use the codes of the REST status codes: UNAUTHENTICATED (401),
FORBIDDEN (403), NOT_FOUND (404), and UNPROCESSABLE_ENTITY (422), which
has the REST errors object. An unknown article or profile is null. Follow
and favorite have no effect when repeated, and an update that changes
the title changes the slug.

This replaces the earlier pagination and tag list fields and the
permission fields, because the RealWorld API has none.
Batch the article and profile fields with GraphQL::Dataloader, so a list
uses the same number of queries for any length. Add the missing indexes,
cap offset, and limit query depth and complexity.

Replace Devise with has_secure_password, and merge profiles into users so
a user has bio and image like the RealWorld API. Derive the JWT key from
the key generator, require HS256, and accept Token or Bearer headers.
Return 400 for variables that are not valid JSON.

The comments query returns null for an unknown article, like article and
profile. Remove Spring, the unused Rails frameworks and their tables,
friendly_id leftovers, and stale config and docs. CI runs RuboCop and
checks that schema.graphql is current.
Replace Next.js with a client-side app, because the app has no server
rendering needs. Loaders and actions own data loading and mutations, and
reach Apollo Client only through the router context. Middleware loads the
viewer and guards routes. Components get data as props.

Follow the RealWorld frontend spec for templates, routes, and styles, with
offset and limit pagination. Use the GraphQL Codegen setup that Apollo
Client recommends, with typed gql documents and generated types that are
not committed. Use pnpm, oxlint, and oxfmt.
Vendor the shared e2e suite from realworld-apps/realworld and run it in fullstack mode against the API on port 4000. The suite includes two fixes that are also proposed upstream: the comment sign-in locator and the logout helper race.
Stories render components and pages in a memory router with a mocked Apollo client from storybook-addon-apollo-client. Vitest runs them as browser tests with an accessibility check, and runs unit tests for the helpers in src/lib.
Describe the boundaries between routes, the app layer, components, and helpers, and how to write GraphQL documents and stories.
Run each workflow on pushes to main and on pull requests that change its project. The web workflow checks lint, format, types, tests, and the build, and runs the e2e suite against the API. Group Dependabot updates and add GitHub Actions updates.
Split src/components into ui (primitives with no domain types), layout
(page structure), and features (domain components with their fragments).
The primitives give one place for button styles, labeled form fields, and
tabs, so forms no longer repeat the page wrapper or the field markup.

Use React Router's href to build URLs. The route paths are absolute and
registered for href, so a wrong path or param is a type error. A unit test
checks that the router has the registered paths.

Replace the pathless guard routes with signed-in and guest layout routes.
The signed-in layout gives loaders, actions, and pages a user that is not
null, and the guest layout holds the shared auth page layout.
Use the React Router Vite plugin with ssr: false. Routes come from
src/routes.ts, src/root.tsx replaces index.html and main.tsx, and route
modules use clientLoader, clientAction, and clientMiddleware. Typegen gives
each route typed params, loaderData, and actionData, and registers the
paths for href, so the hand-written page list and its test are gone.

Pass route params straight to GraphQL variables where the names match, and
split the editor into new and edit article routes. Set page titles with
meta exports. Page stories build their router from src/routes.ts with
createRoutesStub. Router updates do not use transitions, so a new page
shows together with its URL.
The API cleanup removed config/database.yml.github-actions, so the e2e job now sets DATABASE_URL. It also starts the API in the background and waits for /up, instead of the daemon flag.
The Exclude list replaced the defaults, so RuboCop also inspected vendor/bundle, where CI installs the gems. Merge the list with the defaults instead.
In CI, bin/ci runs bin/setup with RAILS_ENV=test, and db:prepare seeds the new test database. The specs need an empty database, so the seeds skip the test environment.
@lifeiscontent
lifeiscontent merged commit 9f9aa41 into main Oct 7, 2026
3 checks passed
@lifeiscontent
lifeiscontent deleted the modernize-web branch October 7, 2026 01:26
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.

Modernize the API and web app

1 participant