ADR-0001: Migrate frontend from legacy PHP to Next.js¶
Context¶
The frontend currently operates as a hybrid: PHP renders backend templates and serves statically built React assets (built via GitHub Actions) for client-side functionality, using Redux and React Query for client state — see Frontend, Tech stack, and React rewrite.
Pages that have not yet been converted from PHP remain critical for SEO, since the React SPA is client-side only and has no server-rendered HTML for crawlers.
Decision¶
Unify the frontend stack under the Next.js App Router, enabling Server-Side Rendering (SSR) for SEO-critical pages while preserving the existing Redux + React Query client state and CSR-only pages.
Phase 1 — Lift and shift existing client-side React pages¶
The existing React app is inherently CSR-only, so this phase requires minimal refactoring:
- Client Component designation — add the
'use client'directive to existing page components so they continue to run client-side only, preserving compatibility with browser-only APIs and hooks. - State management integration — port the existing Redux store and React Query
configuration into a single
<Providers>Client Component wrapping the Next.js root layout. - Authentication checks — since these pages render client-side, route protection must happen before the JS payload executes. Use Next.js Middleware to intercept requests and redirect unauthorized users before the page renders.
Phase 2 — Convert legacy PHP pages for SEO¶
Pages not yet converted from PHP need SSR so crawlers see fully populated HTML on first load, while still giving the React Query developer experience:
/v2API endpoint creation — each legacy PHP page currently renders its data server-side with no JSON endpoint behind it. Before a page can move to Phase 2, add a corresponding/v2/...endpoint (see API spec) that returns the same data the PHP template used, sofetchPost/fetchComments-style client functions have something to call from both Server and Client Components.- Server Component data fetching — fetch data directly inside Next.js Server Components so the initial HTML payload is fully populated.
- TanStack Query hydration — prefetch data on the server with a server-side
QueryClient, matching the client-side React Query setup. - Dehydration and boundary wrapping — dehydrate the prefetched server cache and
pass it to the client via
<HydrationBoundary>, souseQueryhooks on the client consume the SSR data instantly with no extra network request or loading spinner.
Fetching on the server (Server Component):
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/get-query-client';
import { fetchPost } from '@/lib/api';
import PostContent from './PostContent';
export default async function PostPage({ params }: { params: { id: string } }) {
const queryClient = getQueryClient();
// Prefetching data on the server
await queryClient.prefetchQuery({
queryKey: ['post', params.id],
queryFn: () => fetchPost(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostContent id={params.id} />
</HydrationBoundary>
);
}
Retrieving content on the component (Client Component):
'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchPost } from '@/lib/api';
export default function PostContent({ id }: { id: string }) {
// Retrieves data from the cache instantly without a new network request
const { data } = useQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id),
});
return <div>{data?.title}</div>;
}
Fetching on demand (e.g. on user interaction):
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { fetchComments } from '@/lib/api';
export default function LoadCommentsButton({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const handleLoadComments = async () => {
// Fetch and cache data on demand
await queryClient.fetchQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId),
});
};
return <button onClick={handleLoadComments}>Load Comments</button>;
}
Execution & infrastructure¶
We adopt a Big Bang migration: cut over all pages from legacy PHP to Next.js in a single unified deployment, rather than maintaining proxy routing rules between old and new systems during an incremental rollout.
The current EC2 host OS does not natively support the Node.js versions Next.js requires. Containerization plus CI/CD bypasses this without an immediate OS upgrade:
- Dockerization — a Docker image encapsulates the Next.js app and its required Node.js runtime, ensuring environment consistency independent of the host OS.
- AWS ECR storage — built images are pushed to Amazon Elastic Container Registry.
- CI/CD via GitHub Actions — on commits to
main, build the Docker image, authenticate with AWS, and push the tagged image to ECR. - EC2 deployment — the EC2 instance periodically pulls the latest image from ECR and runs the container.
GitHub Actions pipeline (.github/workflows/deploy.yml):
name: Build and Push Next.js Image to Amazon ECR
on:
push:
branches:
- main
jobs:
build-and-push:
name: Build Docker Image and Push to ECR
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, Tag, and Push Image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: my-nextjs-app
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# Tag as latest and push again
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
EC2 deployment execution (run on the instance to pull and run the updated container):
# 1. Authenticate Docker with your ECR registry
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin <your-aws-account-id>.dkr.ecr.us-east-1.amazonaws.com
# 2. Pull the latest Next.js image
docker pull <your-aws-account-id>.dkr.ecr.us-east-1.amazonaws.com/my-nextjs-app:latest
# 3. Stop and remove the old container
docker stop nextjs-app || true
docker rm nextjs-app || true
# 4. Run the new container, mapping port 80 to the internal Next.js port
docker run -d -p 80:3000 --name nextjs-app <your-aws-account-id>.dkr.ecr.us-east-1.amazonaws.com/my-nextjs-app:latest
Consequences¶
Positive¶
- Single unified frontend stack (Next.js) instead of PHP + separately built React assets.
- Native SSR gives SEO-critical pages server-rendered HTML without a separate rendering path.
- Existing Redux/React Query code and developer experience carry over largely unchanged.
- Docker + ECR decouples the app's Node.js version from the EC2 host OS.
Negative¶
- Big Bang cutover has no incremental rollback path — a bad deploy affects the whole site at once.
- All pages must be migrated before cutover, which is a large, high-risk single release.
- Requires the EC2 instance to run Docker and reliably pull/restart the container on deploy.
- Every legacy PHP page needs a new
/v2API endpoint before it can be converted, adding backend work beyond the frontend rewrite.
Alternatives considered¶
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Big Bang (chosen) | Single cutover, no dual-system maintenance | No incremental rollback, all-at-once risk | Accepted risk to avoid proxy complexity |
| Incremental / strangler-fig (page-by-page) | Lower risk per release, gradual rollout | Requires proxy routing rules between PHP and Next.js during the transition | Adds architectural complexity we want to avoid |
Follow-up tasks¶
- [ ] Inventory every legacy PHP page and the data each one currently renders
- [ ] Create a
/v2API endpoint for each legacy PHP page's data (prerequisite for Phase 2) - [ ] Write the Next.js
Dockerfile - [ ] Provision the
my-nextjs-appECR repository - [ ] Add
.github/workflows/deploy.ymlto the frontend repo - [ ] Set up the EC2 pull/restart step (cron or systemd unit)
- [ ] Update Tech stack, React rewrite, and API spec once new endpoints and cutover are complete
- [ ] Retire the legacy PHP frontend docs after migration