AWS

Migrating my site from Hugo to Next.js on AWS Amplify

How I moved manuel-vogel.de to vogel-johnson.com: Next.js on Amplify SSR, a CDK-built lead pipeline with DynamoDB and SES, and a 301 cutover that didn't break my SEO.

Aug 30, 2026 · 17 min read

Photo from pexels

Introduction

I ran manuel-vogel.de on Hugo for three years. Static site, S3, CloudFront, a self-mutating CDK pipeline I built from scratch in 2023. It worked, it was cheap, did the job, and I was proud of it. But it could only ever serve files. No forms that do anything (well, I had one, which got a bunch of spam), no real lead capture, nothing server-side, nothing really dynamic.

So this summer I rebuilt the whole thing: Next.js 16 on AWS Amplify Hosting, a proper lead pipeline with DynamoDB and SES, and a new domain, vogel-johnson.com. This post is the technical walkthrough, with the AWS side front and center, since that's the part that actually took thought.

The challenge

The old stack, in one line: Hugo build -> Docker bundling assets in CDK -> CodePipeline -> S3 -> CloudFront, with a hand-rolled Lambda@Edge function to redirect / to /index.html and fixed replies for 403/404. It was self-contained, which I liked, but every extra feature meant more Lambda@Edge code, and this is a genuinely unpleasant place to debug anything: no environment variables, only js, and a small payload only.

What I actually wanted this time:

  • A real contact form and newsletter with double opt-in, not a mailto: link.
  • Content still built at compile time, no client-side markdown rendering, no waiting on an SSR render for a blog post that never changes.
  • A migration path that didn't quietly erase three years of SEO.
  • Again, something I could run entirely on my laptop before touching a single real AWS resource.

None of that fits cleanly into "static site on S3." It needs actual server compute, and I didn't want to hand-roll that compute layer myself again. Also, I didn't want to code the dev environment myself again. Back then in 2023, it was a good exercise, but this time I wanted most things out of the box. If you are interested, you can read the old blog post. For now, let's jump right into the solution.

The solution

Framework and hosting

I picked Next.js 16 App Router with Tailwind Plus UI blocks, restyled into my own dark-first design system, and deployed it on AWS Amplify Hosting with managed SSR compute rather than the CloudFront/S3/Lambda@Edge stack I'd built by hand before. Instead of owning the pipeline (CodePipeline, custom Docker build, Lambda@Edge redirects), Amplify owns the build-and-deploy loop, and I just tell it what to build via amplify.yml.

amplify.yml
version: 1
frontend:
  phases:
    preBuild:
      commands:
        # Pin Node to .nvmrc (22) instead of Amplify's default image Node; raise the heap so
        # the Next build does not OOM on large content sets.
        - nvm install
        - nvm use
        - export NODE_OPTIONS=--max-old-space-size=8192
        # Decrypt the branch-appropriate committed SOPS env file (.env.production.enc on `main`,
        # .env.development.enc on every other branch) with the KMS key the Amplify service role
        # can decrypt, and stamp in the values that must vary per build (LEADS_TABLE_NAME always;
        # SITE_BASE_URL + SITE_INDEX on preview branches). See scripts/amplify-branch-env.sh -
        # Amplify app/branch env vars are build-time only and never reach the SSR compute runtime,
        # so runtime config is delivered via the `.env` this produces (Next.js loads it at startup).
        - SOPS_VERSION=v3.13.1
        - curl -sSfL -o /tmp/sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64"
        - chmod +x /tmp/sops && sudo mv /tmp/sops /usr/local/bin/sops
        - bash scripts/amplify-branch-env.sh # <- does the branch magic
        - npm ci --legacy-peer-deps
    build:
      # BUILD_STANDALONE intentionally unset here: Amplify WEB_COMPUTE expects the default
      # .next output, not the Docker standalone bundle (see next.config.ts + Dockerfile).
      commands:
        - npm run build
  artifacts:
    # baseDirectory for Amplify Next.js SSR (WEB_COMPUTE) is .next.
    baseDirectory: .next
    files:
      - "**/*"
  cache:
    paths:
      - node_modules/**/*
      - .next/cache/**/*

Content is still build-time. Blog posts, case studies, and legal pages are MDX files loaded through a typed content API, compiled with @next/mdx and rehype-pretty-code for syntax highlighting, no client-side highlighter, no ISR, no edge streaming. Amplify's managed Next.js runtime doesn't support those anyway, and I didn't need them: a blog post doesn't change after I hit publish.

The lead pipeline: DynamoDB and SES via CDK

This is the part the old site never had. Contact form, newsletter subscribe with double opt-in, unsubscribe, gated content downloads, training requests: all real route handlers under src/app/api/, all writing to a DynamoDB table and sending mail through SES.

Infrastructure is CDK, eu-central-1, split into small constructs:

cdk/lib/
  leads-table.ts       # DynamoDB table for lead records
  sending-identity.ts  # SES domain identity + DKIM
  ssr-hosting.ts       # the Amplify App/Branch resources
  sops-key.ts          # KMS key for encrypted env
  edge-protection.ts   # WAF (optional)
  observability.ts     # alarms

Locally, none of this touches real AWS infrastructure. I run MiniStack, a free, MIT-licensed LocalStack replacement, as a single Docker container exposing DynamoDB and SES on port 4566. The same application code targets either backend; the only difference is whether AWS_ENDPOINT_URL is set. Sent mail during local dev is visible at /_ministack/ses/messages, which the Cypress E2E suite actually asserts against, no fake mail server, no manual inbox checking.

The data model is one DynamoDB table with composite keys, not one table per lead type: subscribers, confirm tokens, contact messages, and gated-content leads each get their own record-type prefix on the same partition/sort key pair, plus a secondary index so the admin view can list subscribers by status without a full table scan. Confirm tokens carry a ttl field a couple of days out, so DynamoDB expires them on its own, no cleanup job needed. One table, one billing line, and the sort key does the filtering.

Confirming a subscription is the one place a naive read-then-write would have created a real bug: two near-simultaneous hits on /api/confirm, a double-clicked email link, or a bot replaying it, could both read "not yet confirmed" and both decide to confirm. Instead it's a single TransactWriteItems call that flips the subscriber to confirmed and marks the token used atomically, each write conditioned on the item's prior state. If the transaction gets canceled because someone already claimed it, the handler re-reads and returns whichever state won, instead of erroring out on a user who already succeeded once.

Unsubscribe tokens are stateless on purpose: an HMAC-SHA256 of the lowercased email against a server secret, not a stored token, so there's no reverse lookup and no extra item to write. GDPR retention rides the same TTL field instead of a cron job. Contact submissions and gated leads expire after roughly two years; a subscriber record only gets a TTL once they unsubscribe, kept an extra year as evidence the withdrawal happened.

Two smaller, honest decisions worth naming. Captcha verification has a local/CI escape hatch that skips the real Cloudflare Turnstile call and short-circuits straight to a pass, which is what keeps automated tests from depending on a live third-party call succeeding on every run, guarded so it can only ever be true outside production.

Guardrails: CDK-NAG, WAF, and the alarm I didn't add

Every stack has to pass CDK-NAG and cfn-lint before it's allowed to deploy (npm run validate), and the suppression list stays short on purpose: three suppressions, each scoped to one exact resource, not a blanket allow on the stack. AWS Backup's auto-generated service role uses an AWS-managed policy (unavoidable, that's how Backup works), Amplify's build role needs AdministratorAccess-Amplify (scoped only to the hosting construct, since the actual SSR runtime role stays least-privilege), and the DynamoDB grant for GSI access uses a wildcard scoped by regex to /index/*, not to the whole table.

WAF is opt-in, -c enableWaf=true, and costs nothing by default. Turned on, it's a regional WebACL (Amplify's compute is regional, not CloudFront-at-the-edge, so it can't live in the usual us-east-1 WAF scope) running AWS's managed common rule set plus a rate-based rule that blunts per-IP request bursts, for a modest monthly cost. Let's see how the site evolves and if I need to add WAF. On the previous site, I saw many calls to /admin.php, etc.

edge-protection.ts
export class EdgeProtection extends Construct {
  readonly webAcl?: CfnWebACL;
 
  constructor(scope: Construct, id: string) {
    super(scope, id);
 
    const flag = this.node.tryGetContext("enableWaf");
    if (flag !== true && flag !== "true") {
      return;
    }
 
    this.webAcl = new CfnWebACL(this, "WebAcl", {
      defaultAction: { allow: {} },
      scope: "REGIONAL", // we are not in us-east-1, so this is a regional WAF for Amplify Hosting SSR
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "homepageTwplusWebAcl",
        sampledRequestsEnabled: true,
      },
      rules: [
        {
          name: "AWSManagedRulesCommonRuleSet",
          priority: 0,
          overrideAction: { none: {} },
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesCommonRuleSet",
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            metricName: "commonRuleSet",
            sampledRequestsEnabled: true,
          },
        },
        // as of now ... let's see if we need more rules...
        {
          name: "RateLimitPerIp",
          priority: 1,
          action: { block: {} },
          statement: {
            rateBasedStatement: {
              limit: 1000,
              aggregateKeyType: "IP",
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            metricName: "rateLimitPerIp",
            sampledRequestsEnabled: true,
          },
        },
      ],
    });
  }
}

For alarms: SES bounce and complaint rate alarms tuned to catch a sending reputation problem well before AWS's own thresholds would throttle or suspend sending, both single-period so they don't wait around before paging, and both configured not to treat missing data as a breach. A separate EventBridge rule watches for failed Amplify deployment events and feeds the same SNS topic, since there's no CloudWatch metric for Amplify build status. What I deliberately didn't add is an SSR 5xx alarm: Amplify's managed runtime doesn't expose a stable per-app 5xx metric, and an alarm on a number that doesn't reliably mean what you think it means is worse than no alarm at all. I'd rather have a visible gap than a false sense of coverage. I did add one boring but necessary thing: a dedicated log group for the SSR compute logs with a short retention window and automatic cleanup, because Amplify's default logging has no retention limit and will happily accumulate forever otherwise.

Secrets: SOPS in, CloudFormation dynamic references where SOPS can't reach

Two different secret-handling patterns show up here, and it's worth being precise about which is which.

App runtime config and secrets live together in a single SOPS-encrypted file, .env.production.enc, committed to the repo. It's decrypted to .env in the Amplify build's preBuild phase using a KMS key that only the Amplify service role can decrypt:

run this locally to encrypt a new .env.production.enc
sops --input-type dotenv --output-type dotenv -e .env.production > .env.production.enc
git add .env.production.enc

Infrastructure-level secrets (the GitHub token Amplify needs for CI, the Basic Auth passwords for pre-launch and preview branches) go through CloudFormation dynamic references instead: {{resolve:secretsmanager:...}}, resolved at deploy time by whoever is running cdk deploy, not by Amplify at runtime. That distinction matters for IAM: the SSR compute role has zero secretsmanager: permissions. It never needs them, because it never reads Secrets Manager. CloudFormation already baked the resolved value into the Amplify branch config before the app ever ran.

That second pattern also produced an ugly bug. Turning off Basic Auth on the main branch should have been one flag, -c siteBasicAuth=false. It wasn't, twice:

  1. @aws-cdk/aws-amplify-alpha's Branch construct just omits BasicAuthConfig from the template when basicAuth is undefined, instead of emitting enableBasicAuth: false. CloudFormation's Amplify handler treats an omitted property as "no change" on update, so the already-live branch kept Basic Auth on even after redeploying with the flag off. Fixed with an explicit addPropertyOverride on the L1 resource.
  2. Second attempt failed validation entirely: AWS::Amplify::Branch's real API schema requires Username and Password on BasicAuthConfig regardless of EnableBasicAuth. My override only sent EnableBasicAuth: false. Fix was to keep sending the same secret-backed username/password dynamic references as the enabled path, just with the flag flipped: inert once disabled, but present.

Two failed deploys for what should have been a boolean. That's the kind of thing you only find by actually running cdk deploy against the real API, not from reading the L2 construct's TypeScript types. But it is still in alpha state, so there is room for improvement. For now, I am fine with the workaround.

Branch previews without touching production

Once the lead pipeline had a real database behind it, I wanted feature branches to preview against their own data, not production's. One more cdk deploy wired up Amplify's auto branch creation for feat/*, fix/*, and chore/* branches: a separate leads table for preview traffic, its own Basic Auth credentials, its own SOPS-encrypted env file, and auto subdomain creation turned off for the custom domain, so no preview branch can ever pick up a public subdomain by accident. Previews only ever exist at *.amplifyapp.com.

ssr-hosting.ts
export class SsrHosting extends Construct {
  readonly app: App;
  readonly computeRole: Role;
 
  constructor(scope: Construct, id: string, props: SsrHostingProps) {
    super(scope, id);
    // ...
    // Only feat/*, fix/*, and chore/* branches are auto-connected, built as Next.js SSR, and
    // pointed at the shared preview table - no `cdk deploy` per branch. Scoped to these
    // prefixes (rather than "all repository branches") so bot/throwaway branches
    // (dependabot/renovate, ad-hoc spikes) never get a build against the shared preview table.
    autoBranchCreation: {
      patterns: ["feat/*", "fix/*", "chore/*"],
      basicAuth: devBasicAuth,
      autoBuild: true,
      pullRequestPreview: false,
    },
    // Deleting the branch on GitHub removes its Amplify preview - no manual cleanup.
    autoBranchDeletion: true,
    // ...
  }
}

The one non-obvious part: the table name only reaches the SSR runtime through the .env a build writes. Updating the Amplify app resource via CDK doesn't rebuild the already-deployed main branch, so after renaming the production table, I had to trigger a fresh build of main before it would pick up the change, otherwise, it keeps writing to the orphaned old table.

The hydration bug that looked like flaky tests

The strangest bug of the whole migration didn't show up in manual testing. It showed up as Cypress randomly reporting form inputs as detached from the DOM mid-keystroke, intermittent, only under E2E, never reproducible by hand.

The actual cause was the dark-mode theme script: a small inline <script> in <head> that reads localStorage and sets the dark class before first paint, so there's no flash of the wrong theme. Very standard pattern. What isn't standard is how React 19 treats it: that inline script renders into the server HTML, but React's client-side render tree never includes it at all. Scripts inside components simply don't execute on the client render pass. That means the server tree and the client tree differ in element count from the very first render, which trips React error #418. React's response to #418 is to throw away the entire server-rendered DOM and regenerate it from scratch on the client. I confirmed it with a MutationObserver: the footer got removed (6416 characters) and re-added (6376 characters) 122 milliseconds after load, a full re-hydration nobody asked for, right as a real user, or Cypress, might be typing into a field that gets swapped out from under them.

Next.js's own documented fix for this pattern, type="text/plain" plus suppressHydrationWarning, didn't work, at least not on Next 16.1 with React 19.2. The actual fix was switching the theme script to next/script with strategy="beforeInteractive", which runs through Next's own script injection path instead of React's reconciliation, so it's never part of the tree React compares in the first place. No flash of unstyled content either: the dark class now lands around 72 milliseconds in, well before first contentful paint at roughly 1000 milliseconds.

A second, unrelated source of the same flakiness was hiding in the same test runs: the Cloudflare Turnstile widget script was loading during E2E regardless of the disable flag, injecting its own DOM about 207 milliseconds after load, an entirely separate race dressed up as the same symptom. And there was a pre-existing workaround in cypress/support/e2e.ts that blanket-suppressed React errors 418, 423, and 425, which had been quietly eating the real signal the whole time. Removing that suppression was part of the actual fix, not an afterthought, so the next hydration regression fails loudly instead of vanishing into a "known flaky" bucket.

I am happy to have the e2e test suite next to the unit tests, to ensure after each change the functionality remains intact.

tree cypress/e2e
cypress/e2e
├── calendar-booking.cy.ts
├── card-image-link.cy.ts
├── community-work-meetup-feed.cy.ts
├── consent-banner.cy.ts
├── contact.cy.ts
├── ga4-consent-gating.cy.ts
├── gallery.cy.ts
├── gated-content.cy.ts
├── hotjar-consent-gating.cy.ts
├── hydration.cy.ts
├── mermaid-render.cy.ts
├── post-image-fullscreen.cy.ts
├── subscribe-confirm.cy.ts
├── testimonial-handle-link.cy.ts
├── tools-filter.cy.ts
├── training-request.cy.ts
└── unsubscribe.cy.ts
 

The domain cutover I looking forwards to

New domain, vogel-johnson.com, associated to the Amplify app with app.addDomain(). Since DNS already lived in Route53, Amplify auto-discovers the zone by name and writes both the ACM DNS-validation record and the root/www alias records itself, no manual CNAME pasting.

One AWS quirk worth knowing: the default *.amplifyapp.com domain cannot be disabled. There's no toggle, no API for it. So instead of deleting it, I 308-redirect it to the real domain from inside the app's redirects() config, scoped to exclude _next/ and api/ so it doesn't interfere with platform health checks. Both that redirect and the legacy-domain one below had to live in Next's redirects() config rather than Amplify's own redirect rules, because Amplify's native rules only match on path and country, not host, and matching by host is exactly what both of these need.

For the legacy manuel-vogel.de domain, I mapped the full old URL scheme onto the new one instead of a blanket redirect-to-homepage:

Old (manuel-vogel.de)New (vogel-johnson.com)
/posts, /posts/:slug/blog, /blog/:slug
/works, /works/:slug/contributions, /contributions/:slug
/services/solutions
/tags/:tag/blog/tag/:tag
anything else/ (soft fallback)

Derived it straight from the live manuel-vogel.de/sitemap.xml, cross-checked every slug against the migrated content so nothing 404s. One thing: skipTrailingSlashRedirect: true had to go in next.config.ts, because Next's built-in trailing-slash-strip redirect otherwise fires before custom redirects are evaluated, and every single Hugo URL ends in /, so without that flag they'd all get swallowed into a same-host redirect loop instead of crossing to the new domain.

And a genuinely surprising one: Google Search Console's Change of Address wizard validates the homepage redirect status code itself, not just whether the redirect works. I originally emitted a 308 (Next's default for permanent: true), confirmed with curl that it worked correctly, and GSC still failed the check with a red X. Google's ranking algorithm treats 301 and 308 identically; GSC's own validator apparently doesn't. Switching that one redirect rule to a literal statusCode: 301 fixed it. Every other redirect on the site stayed 308, since GSC only checks that specific migration flow.

Basic Auth had to come off before the legacy domain went live, too: it's enforced at the Amplify CDN layer, ahead of any app code, so Googlebot would have hit 401s instead of 301s if I'd sequenced it the other way.

Conclusion

Tearing down the old CDK pipeline afterward turned up its own small mess: a production S3 bucket kept alive by RemovalPolicy.RETAIN with no auto-delete, two orphaned ACM validation CNAMEs left behind by a custom resource on destroy, and about 7.4 GB of stale CodePipeline build artifacts sitting in S3 for no reason. Worth checking for on any teardown: cdk destroy only removes what the stack directly owns, not everything the stack ever touched.

If I total up the git history for the actual build (excluding the original Next.js scaffolding and dependency bumps), the design, content migration, and lead pipeline slices came to roughly ten hours of active work spread across a week. That number is a floor, not a ceiling: the infrastructure and DNS slices show up as single-commit sessions, because almost all of the CDK thinking happens before the one commit that deploys it, so the real time spent on those two slices is close to invisible to git.

Would I migrate the same way again? Yes, to Amplify managed SSR over hand-rolling my own pipeline, I'd rather debug a route handler than Lambda@Edge ever again. The one thing I'd do earlier next time is write the legacy redirect map before the new site design is even final, since it's the one piece that depends on nothing else and blocks the whole cutover at the end.

As always, I used my pet project to learn a few new things, and this time it was Next.js 16 App Router, Amplify Hosting SSR, and a few new AWS services I hadn't used before. Let's see how the site evolves over the next year, and if I need to add WAF or other security measures and probably a whole admin UI with user management.For now, it's a clean, fast, and functional site with a proper lead pipeline and no more Lambda@Edge headaches.

This post was written with AI assistance and verified plus enhanced by a human.

More from the blog

Keep reading - related notes from recent engagements.

DevOps

Advanced GitLab CI features in v19

A tour from job grouping to dynamic child pipelines, and a look at the experimental functions feature, every example run on a real GitLab 19 instance.

Ready to ship faster on AWS?

Tell us what you are building. We will map the fastest safe path to production and the platform to keep it there.

Notes from production

Occasional, no-fluff writing on AWS, DevOps, and running platforms that stay up. No spam, unsubscribe anytime.

Practical, not promotional
Real lessons from real engagements - architecture, automation, and incident post-mortems.
No spam
A few emails a year at most. Your address is never shared or sold.