Skip to content
Expert Cloud & AI
Menu

From Static WordPress to Next.js and MDX: An AI-Assisted Migration with Claude, GPT and Superpowers

How we migrated this site with AI-assisted development, preserved its content, and applied lessons from the Otari technical resource.

Chris Baran

· Updated 24 September 2026 · 15 min read

On this page
  1. Before and after
  2. Give content an explicit contract
  3. Use a workflow that leaves reviewable decisions
  4. Preserve content, not just file counts
  5. Test the behaviour that actually matters
  6. Beyond migration: what the Otari build made possible
  7. Reduce the exposed application surface
  8. Keep dynamic work in small, scoped Lambda functions
  9. Deliver a fast browsing experience from the edge
  10. Treat deployment as part of the migration
  11. Make the next change easier to verify

The original Static WordPress with CloudFront article described the previous version of this site. WordPress supplied the authoring environment, Simply Static produced the export, and GitLab CI deployed those files to Amazon S3 and CloudFront.

This follow-up explains the next step: replacing the exported WordPress source with a Next.js application and MDX content that developers and AI agents can edit directly. The AWS delivery foundation remains, with infrastructure and pipeline changes to support the new build.

Claude and GPT models participated across the development process. The Superpowers plugin provided a workflow for specifying, planning, implementing and reviewing changes. That workflow connected the repository, content checks and human decisions throughout the migration.

Before and after

The previous content path was WordPress → Simply Static → exported files → GitLab deployment → S3 and CloudFront.

The new path is MDX and React source → Next.js static build → GitLab deployment → S3 and CloudFront.

The distinction matters when making a change. Instead of editing an exported page or returning to WordPress to regenerate it, we can work on an article in content/insights/, a shared component in components/, or a route in app/. Infrastructure and pipeline definitions are versioned alongside that source.

Next.js is configured for static export. It produces out/, including HTML and browser assets, for deployment. There is no Next.js application server handling each page request, and no runtime LLM is required to read the site. Interactive browser components can still operate on those static pages.

The static-export settings are explicit in the application configuration:

next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
  agentRules: false,
};
 
export default nextConfig;

output: "export" produces deployable files in out/. Trailing slashes match the directory-index routing at CloudFront. Images are served without a request-time Next.js image optimiser; image preparation happens separately.

This “LLM-native” source structure gives developers and agents readable files, explicit conventions and checks they can run before proposing a change.

Give content an explicit contract

Each Insight has an index.mdx and a line-art cover.svg. Front matter supplies the title, summary, dates, topic, format and service relationships. MDX provides prose and selected reusable components without duplicating the article layout.

The content loader validates that structure. Dates must be quoted strings. Unknown front-matter fields fail validation, so a typo such as drafts cannot silently stand in for draft. References to services and related articles must resolve. Covers also have structural checks, including a standard viewBox and a text label.

Drafts use draft: true. They can be previewed in development, while production listings and generated article paths exclude them. The build also prunes their designated media folders from the export. Those controls are useful publishing boundaries; they do not make a public repository a place for confidential drafts.

Repository instructions document conventions that an agent needs to preserve, including typography, heading style and content restrictions. Some are checked automatically; others still need review. Keeping that distinction explicit avoids mistaking a written rule for an enforced one.

Use a workflow that leaves reviewable decisions

Superpowers structured the work into an agreed design, implementation plans, scoped tasks, review and verification. Those artefacts gave agents a concrete contract and gave the owner something more useful to assess than a large unexplained diff.

Explore Superpowers on GitHub →

Bounded autonomy for more than 10 hours

With that structure in place, the LLM could work autonomously for more than 10 hours on this project, carrying implementation tasks through their checks and returning working changes for review.

The boundaries mattered: the specification described the intended result, the plan divided the work, and review and verification supplied checks against the implementation. That allowed the agent to carry a substantial body of work forward without a person directing every edit. Human decisions still set the scope and determined whether the result was acceptable to publish.

Claude and GPT models supported planning, implementation and review. The shared specification, source and checks let work move between them without losing the agreed design.

Human feedback determined whether the design served a marketing site, which content changes were acceptable and when to deploy. Agent review helped find problems, but agreement between models was never sufficient evidence on its own. We still needed checks against source material and the rendered pages.

The repository also acted as the handover between sessions and tools. A useful handover identifies the agreed scope, current state, decisions, unfinished work and commands used to verify it. That makes a change of model or development environment manageable without assuming that a new session remembers the earlier conversation. Credentials and private operational details do not belong in a public handover document.

Preserve content, not just file counts

The migration covered eight articles as well as the About and legal pages. The captured website supplied the source material. Extracting text programmatically helped preserve curly punctuation and avoid retyping errors.

We compared the captured content with the rendered output and documented intentional differences. These included duplicate title text and a damaged article that contained a pasted copy of another post. Removing that pasted section required an explicit decision; the migration was not permission to rewrite the remaining article.

The parity report used a word-multiset comparison to count lost and added words. That method has an important limit: the same words in a different order still match. Paragraph-level comparisons were needed to establish sequence and wording, alongside browser checks for presentation.

Rendering could introduce subtler differences. Three numbered statements became separate Markdown lists until their periods were escaped. A paragraph with touching emphasis spans needed explicit markup to preserve the source formatting. These issues would be easy to miss if acceptance stopped at “the MDX compiles”.

Test the behaviour that actually matters

One implementation omitted the About page’s “Simplify. Scale. Succeed.” labels. Review restored them, then a specific regression check was added. Removing “Scale.” deliberately caused that check to fail, providing evidence that it guarded the missing element.

Other checks covered content validation, article routes, feed entries, sitemap URLs and share images. These answer different questions from a browser review. A route can exist while its mobile layout overflows; content can be present while its hierarchy makes the page hard to follow.

The visual work needed another feedback loop. The owner compared the new pages with the original marketing site and directed changes to the homepage, About layout and hover treatments. Preserving the purpose of the site required more than moving its words.

Beyond migration: what the Otari build made possible

The build-out of otarimx5050.com shows how a related Next.js and MDX approach can support a specialised technical resource. It is a separate project with its own components and content model; the features below are not all present on this business site.

The site was also the basis for practical restoration work. With limited formal training and study in electrical engineering, I used the resource and its underlying AI-assisted research to complete a full recap and calibration of the recorder without an expert guiding the work. Developing the website helped organise the material I needed to understand and apply: manuals, diagrams, component explanations and restoration notes.

This gives the project a concrete outcome beyond publishing content. It demonstrates my experience of using AI to work into an unfamiliar technical domain and carry out a demanding task. The software features below helped make that knowledge usable.

None of the copy on the Otari site was edited directly in an editor. We reviewed the output and returned prompts requesting specific revisions, which the LLM then applied. The workflow included human editorial feedback without manual text editing. For the digital editions, the task was to preserve and structure existing source text; for explanatory material, it included developing the copy. That distinction matters when assessing the result: faithful transcription and a sound technical explanation require different checks.

Digital editions use a reusable publishing structure. An edition directory contains an edition.json manifest and section MDX files. Generic routes render the edition and its sections. This lets additional manuals and brochures use an established structure rather than requiring a new route implementation for every document.

Diagrams retain a route back to the source. The image viewer supports zoom and pan, with a switch between a vector redraw and the original scan where both exist. Readers can inspect a clearer drawing and compare it with the source. Use the comparison to check labels, connections and other details against the original.

Annotations distinguish transcription from correction. A source-note component preserves the printed wording and offers an explanation with a crop from the scan. This avoids silently presenting an editorial correction as original text. The library provides examples of the digital material.

MDX can host interactive teaching components. The Inside the Audio page includes a clickable signal-path explorer and a capacitor demonstration with a slider. React components handle the interaction, embedded alongside the explanation. This makes a static site capable of richer learning experiences without turning every page into a server-rendered application.

Search is a build artefact. Both sites now use Pagefind to index their static exports after the build. The browser searches those downloaded index files, and each content build refreshes the index. On Expert Cloud & AI, visitors can search published insights, case studies and services, with About included in the full results.

Together these features suggest a useful direction for document modernisation: structured editions, reusable interactions and visible source evidence. Applying that pattern to an enterprise library would also require decisions about permissions, source rights, document versions and specialist review.

The broader connection between the projects is architectural: both put content and presentation into source that can be inspected, changed and rebuilt. It is possible to reuse that approach without pretending that a marketing site and a restoration archive need the same features or the same editorial workflow.

Reduce the exposed application surface

Static delivery is also a security architecture decision. A normal page request retrieves built files through CloudFront from S3. It does not invoke a CMS, query an application database or execute a Next.js page handler. Removing those request-time components substantially reduces the application attack surface compared with an equivalent dynamically hosted site.

The origin is a private S3 bucket with public access blocked. CloudFront uses Origin Access Control to read it; we use the S3 regional endpoint rather than a publicly accessible S3 website endpoint. Viewers receive the site through CloudFront, and the content delivery behaviour permits GET and HEAD. This follows AWS’s guidance for restricting access to an S3 origin.

This excerpt connects CloudFront to the regional S3 origin through the separately defined access control:

expertcloud_infra.yaml — origin excerpt
Origins:
  - DomainName: !GetAtt WebsiteBucket.RegionalDomainName
    Id: S3Origin
    OriginAccessControlId: !GetAtt CloudFrontOAC.Id
    S3OriginConfig: {}

The bucket policy grants s3:GetObject to the CloudFront service only when the source is this distribution. Together with S3 Block Public Access, that keeps the bucket from becoming an alternate public delivery path. This is an excerpt from the distribution, not a complete CloudFormation template.

Browser-side search on both sites extends that approach. Pagefind produces a static index at build time, and search runs in the browser against downloaded index files. There is no search API or query-processing server for a visitor to attack. The index is public content, so it must only contain material intended for publication.

Attack surface and blast radius are related, but distinct. Static delivery removes several entry points; restricted permissions limit what a compromised component can reach. Deployment credentials and the build pipeline still control what visitors receive, and shipped JavaScript still needs review. Those remain important boundaries even when there is no general-purpose application server behind the site.

Keep dynamic work in small, scoped Lambda functions

Contact handling and traffic processing are separate modules with defined inputs and AWS permissions. They do not sit in the request path for ordinary browsing or search on either site.

The contact handler accepts a structured submission, checks the request method and body size, validates fields, applies a honeypot check and verifies Turnstile before sending mail through SES. Its permissions are scoped to its required resources and configured mail destination. The endpoint remains an exposed interface that needs validation and abuse controls; separating it from content delivery makes that interface smaller and easier to reason about.

For example, the handler rejects oversized bodies and malformed JSON before proceeding to field validation and Turnstile:

lambda/contact/handler.ts — request validation excerpt
const raw = event.isBase64Encoded
  ? Buffer.from(event.body ?? "", "base64").toString("utf8")
  : (event.body ?? "");
if (Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES) return respond(413, { ok: false, error: "too-large" });
 
let data: Record<string, unknown>;
try {
  const parsed: unknown = JSON.parse(raw);
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
  data = parsed as Record<string, unknown>;
} catch {
  return respond(400, { ok: false, error: "json" });
}

The constants and respond helper are defined elsewhere in the handler module. This is one validation stage; valid JSON alone does not authorise a submission or cause an email to be sent.

Traffic processing works differently. A scheduled Lambda reads CloudFront logs, writes reports and publishes an aggregate JSON file that the dashboard can read. Visitors do not need access to the log-processing function or raw logs. Its role is scoped to the relevant log and report prefixes, with public output confined to the site’s data prefix. This limits the resources that function can affect if compromised.

The traffic function’s public-write permission illustrates the boundary:

traffic_stack.yaml — one IAM statement
- Sid: WritePublicAggregate
  Effect: Allow
  Action: 's3:PutObject'
  Resource: !Sub 'arn:${AWS::Partition}:s3:::${SiteBucket}/data/*'

This statement grants writes to the data/ prefix, rather than to the site’s HTML or scripts. The function has separate permissions for reading logs and writing private reports. Assessing its total reach requires reviewing the complete role; this excerpt shows the public-output permission only.

Using S3, CloudFront and managed Lambda runtimes removes the need to operate and patch a website host’s OS, web server or CMS middleware. AWS manages the underlying infrastructure and provides managed-runtime updates. We retain responsibility for application code, bundled dependencies, IAM, configuration, and moving to supported runtimes. AWS documents that division in its Lambda runtime shared-responsibility guidance.

The resulting operational model is a static delivery path plus a few narrow service interactions, each with a specific purpose and permission boundary.

Deliver a fast browsing experience from the edge

The same architecture also supports fast browsing. Pages are rendered at build time, and CloudFront can serve cached HTML, images, styles and scripts from edge locations close to readers. A cache hit avoids both a trip to the S3 origin and any application rendering or database work. AWS describes the request flow in how CloudFront delivers content.

Our distribution enables compression and does not forward viewer cookies or query strings to the static origin, allowing requests to share cached objects. The deployment pipeline gives fingerprinted Next.js assets long-lived immutable caching. Content updates are handled through the deployment and invalidation process so that caching and publishing work together.

The fingerprinted asset upload sets the caching policy explicitly. This excerpt is line-wrapped from the pipeline; $S3_BUCKET is resolved earlier in the job:

.gitlab-ci.yml — immutable asset upload
aws s3 sync out/_next/static/ "s3://$S3_BUCKET/_next/static/" \
  --delete \
  --cache-control "public, max-age=31536000, immutable"

The one-year policy is for content-hashed assets, whose filenames change when their contents change. The pipeline uploads them before the HTML that references them, then deploys the remaining files and invalidates CloudFront. HTML does not receive this immutable policy. Here, --delete removes stale objects within the target asset prefix; this command is a deployment excerpt, not a standalone publishing recipe.

That is an effective foundation for a responsive global site: prebuilt content, reusable edge-cache entries and S3 as the origin. Actual latency still depends on cache warmth, the reader’s connection and the assets a page loads.

A first measured sample

On 22 September 2026, we tested the live homepage at https://expertcloudandai.com/ using WebPageTest’s free instant tests. Both reports used desktop Chrome v148 and the WiFi profile: 240/120 Mbps, 2 ms RTT. The results below are one completed run per location, not averages or percentiles.

Test locationTime to first byteLargest Contentful PaintLayout shift (CLS)Total time reported
Council Bluffs, Iowa, USA118 ms0.571 s00.751 s
Amsterdam, Netherlands152 ms1.804 s09.315 s

Both runs reported zero Total Blocking Time. The browser received the first byte in under 160 ms and reached Largest Contentful Paint in under two seconds in these two samples. In both locations, visitors could see the main page content in under two seconds.

The Amsterdam report also shows why those metrics should be distinguished: its total time was 9.315 seconds even though its largest content painted at 1.804 seconds. Fast initial delivery does not mean every resource has finished loading. Diagnosing the remaining load time would require examining the request waterfall.

These results cover desktop Chrome on a fast WiFi profile, with one homepage run per location. CloudFront cache warmth was not measured. Repeated runs on mobile connections and across more regions would show how performance varies for a wider audience. The linked reports include the full test conditions and results.

Treat deployment as part of the migration

The new pipeline builds the site before deploying out/. CloudFront routing handles canonical hostnames, legacy redirects and directory indexes. Unknown pages return an actual 404 rather than the old homepage with a successful status.

Cutover needed coordination: redirects pointing to new pages are only useful once those pages exist in the deployed build. The runbook records the deployment sequence, checks and rollback options. Reversing a source change alone may not reverse infrastructure or routing changes, so recovery needs to account for those separately.

Dynamic services also remain separate from the static Next.js export. The contact implementation uses an AWS Lambda endpoint, not a Next.js server route. At the time of writing, the public form remains disabled pending its endpoint and Turnstile site-key configuration. Shipping the website is distinct from accepting every supporting service as operational.

Deploy content without redeploying infrastructure

We carried over the MX5050 pipeline’s separation between publishing content and changing infrastructure. For a normal push to main, GitLab’s change rules decide whether to include the stack deployment jobs. An article edit still runs the build and tests, uploads the generated site and invalidates CloudFront, but skips infrastructure deployment.

The infrastructure jobs share this rule from our pipeline:

.gitlab-ci.yml — infrastructure change rule
.infra_changes: &infra_changes
  - if: $CI_COMMIT_BRANCH == "main"
    changes:
      - route53.yaml
      - certificate_stack.yaml
      - expertcloud_infra.yaml
      - .gitlab-ci.yml

Both conditions must match: the branch is main, and the push changes at least one listed file. The rule is reused by the DNS, certificate, website infrastructure and DNS-update jobs. These jobs are gated as a group: changing any listed infrastructure template includes the group, rather than selecting just that individual stack. Including .gitlab-ci.yml also exercises the deployment jobs when the pipeline definition changes.

The following shortened excerpt shows how the rule connects to deployment. Authentication, variables and scripts are omitted here; the content job’s main rule is written explicitly rather than inherited from our shared authentication block:

.gitlab-ci.yml — abbreviated job rules and dependencies
deploy_infra:
  stage: deploy_infra
  rules: *infra_changes
  needs:
    - deploy_certificate
 
deploy_content:
  stage: deploy_content
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  resource_group: content_deploy
  needs:
    - build_site
    - job: deploy_infra
      optional: true

optional: true allows the content job to run when the infrastructure job is absent. When that job is included, the dependency still requires it to finish successfully. The content_deploy resource group prevents simultaneous content syncs from competing over the same bucket; queued pipeline ordering is configured separately in GitLab.

The contact and traffic functions have their own change rules:

Changed files on a normal main-branch pushDeployment jobs included
Articles, page components or imagesContent only
DNS, certificate or website infrastructure templateInfrastructure group and content
Contact template or lambda/contact/**Contact and content
Traffic template, lambda/traffic/**, lib/traffic/** or lib/site.tsTraffic and content
.gitlab-ci.ymlAll deployment groups

Changes spanning more than one group include each matching group. The build still bundles the Lambda code on a content-only push; the rules avoid deploying those bundles unnecessarily. These rules detect repository changes, not live infrastructure drift.

The content job does read the existing stack’s outputs to find its destination. For example:

.gitlab-ci.yml — read the existing bucket output
S3_BUCKET=$(aws cloudformation describe-stacks \
  --stack-name "$CFN_STACK_NAME" \
  --query 'Stacks[0].Outputs[?OutputKey==`S3BucketName`].OutputValue' \
  --output text)

This is a read-only lookup. It does not call cloudformation deploy, create a change set or update a stack. Routine publishing therefore follows a short path: build and test → sync to S3 → invalidate CloudFront, while infrastructure deployment remains tied to its declared inputs.

Make the next change easier to verify

The result is an editable source with a repeatable build and explicit checks. A future change can start with its intended behaviour, touch the relevant content or component, and be reviewed against both automated evidence and the browser result.

That is the useful connection between AI-assisted migration and ongoing software development: preserve what matters, make the source understandable, and leave the next developer a way to verify their work.

For the decision-maker’s perspective, read a practical approach to enterprise AI delivery. To explore the approach for an existing system, discuss an AI-assisted migration assessment.