diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml
index 626717f..8de12e7 100644
--- a/.gitea/workflows/ci-cd.yml
+++ b/.gitea/workflows/ci-cd.yml
@@ -31,10 +31,10 @@ jobs:
- name: Type-check
run: npm run typecheck
- - name: Build static site
+ - name: Build standalone Next.js application
run: npm run build
- - name: Test static export
+ - name: Test standalone runtime
run: npm test
- name: Audit production dependencies
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 37d582d..ab114b0 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -49,9 +49,27 @@ Replace `deploy:deploy` and `/srv/portfolio` with the values used for
`DEPLOY_USER` and `DEPLOY_PATH`. Add the public half of `DEPLOY_SSH_KEY` to the
deployment user's `~/.ssh/authorized_keys`.
-The container joins `caddy_proxy` under the `static-site` service name. Caddy
-must also join that network and proxy to `static-site:80`. No host port is
-published by this project.
+The standalone Next.js container joins `caddy_proxy` under the `portfolio-app`
+service name. Caddy must also join that network and proxy to
+`portfolio-app:3000`. No host port is published by this project.
+
+A minimal Caddy route is:
+
+```caddyfile
+portfolio.example.com {
+ reverse_proxy portfolio-app:3000
+}
+```
+
+Caddy forwards the browser's `Accept` header by default; keep that behavior so
+Next.js can negotiate WebP responses. Do not replace the cache headers emitted
+for `/_next/image`. The named `next-image-cache` volume persists `.next/cache`
+between container replacements so optimized variants can be reused.
+
+The application expects immutable media URLs. When an admin or future upload
+workflow replaces an image, write it to a UUID or content-versioned URL instead
+of overwriting the previous object at the same URL. Next.js has no per-image
+optimizer-cache invalidation API.
## Manual deployment
@@ -64,3 +82,10 @@ docker compose --project-name portfolio up --detach --build --remove-orphans
Old versioned release directories are deliberately retained for rollback. They
can be removed manually after a newer release has been proven stable; keep the
directory referenced by the `current` symlink.
+
+## Runtime verification
+
+The production image contains `.next/standalone`, `.next/static`, and `public`,
+and runs the generated `server.js` with Node.js 22 on port 3000. CI smoke-tests
+the English and Persian routes, a built static asset, and `/_next/image` with
+WebP negotiation before deployment.
diff --git a/Dockerfile b/Dockerfile
index 89e562c..6ea8877 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,14 +8,25 @@ RUN npm ci
COPY . .
RUN npm run build
-FROM nginx:alpine AS runtime
+FROM node:22-alpine AS runtime
-COPY nginx.conf /etc/nginx/conf.d/default.conf
-COPY --from=builder /app/out/ /usr/share/nginx/html/
+WORKDIR /app
+ENV NODE_ENV=production \
+ HOSTNAME=0.0.0.0 \
+ PORT=3000
-EXPOSE 80
+RUN addgroup --system --gid 1001 nodejs \
+ && adduser --system --uid 1001 nextjs
-HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
-CMD ["nginx", "-g", "daemon off;"]
+USER nextjs
+EXPOSE 3000
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/ || exit 1
+
+CMD ["node", "server.js"]
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
new file mode 100644
index 0000000..dd82fb8
--- /dev/null
+++ b/PERFORMANCE.md
@@ -0,0 +1,36 @@
+# Performance baseline and targets
+
+The pre-change development build blocked the page behind a preload manifest of
+76 images (about 6.49 MiB encoded). Under the recorded throttled mobile run the
+loader remained visible for about 73 seconds and LCP was about 36 seconds. The
+old initial client chunks were about 346 KiB gzip, including a Three.js chunk of
+about 172 KiB gzip.
+
+Measured pre-change section heights used for intrinsic containment:
+
+| Viewport | Projects | Experience | Skills | Education | Milestones | Footer |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| 390 px | 746 | 3776 | 3384 | 1010 | 3688 | 472 |
+| 768 px | 727 | 2786 | 1877 | 1005 | 2338 | 495 |
+| 1024 px | 745 | 2304 | 1391 | 568 | 1830 | 569 |
+| 1440 px | 797 | 2081 | 1233 | 475 | 1665 | 613 |
+
+Cold-cache acceptance targets are initial JavaScript at or below 200 KiB gzip,
+initial transfer at or below 500 KiB before scrolling, LCP at or below 2.5 s,
+INP at or below 200 ms, and CLS at or below 0.1. Compare medians from three
+mobile-profile runs; treat differences inside run-to-run variance as neutral.
+
+## Post-change measurements
+
+The production standalone build references 198,652 bytes (194.0 KiB) of
+initial JavaScript after gzip. Its estimated English first-view transfer is
+391,274 bytes (382.1 KiB), including compressed HTML, JavaScript, CSS, the hero
+avatar, flags, and the two locale-relevant fonts. No font is force-preloaded;
+the browser requests only the faces used by the active locale. The deferred
+Three.js chunk is 131,036 bytes gzip and is not part of the initial script set.
+
+Three fresh 390 x 844 production-browser runs measured LCP at 1,048 ms,
+1,296 ms, and 1,432 ms (1,296 ms median), with CLS 0 in every run. These local
+runs validate the build and relative improvement but are not a substitute for
+field data or a repeatable hosted-network/mobile-CPU lab profile. INP should be
+tracked in production once real interaction samples are available.
diff --git a/compose.yaml b/compose.yaml
index c2e1de1..74b07c4 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,11 +1,19 @@
services:
- static-site:
+ portfolio-app:
image: amiirkhl-portfolio:latest
build:
context: .
dockerfile: Dockerfile
- container_name: static-site
+ container_name: portfolio-app
restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
+ interval: 30s
+ timeout: 3s
+ start_period: 10s
+ retries: 3
+ volumes:
+ - next-image-cache:/app/.next/cache
networks:
- caddy_proxy
@@ -13,3 +21,6 @@ networks:
caddy_proxy:
external: true
name: caddy_proxy
+
+volumes:
+ next-image-cache:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index be57f57..f4b6232 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -5,5 +5,5 @@ import nextTypeScript from 'eslint-config-next/typescript';
export default defineConfig([
...nextVitals,
...nextTypeScript,
- globalIgnores(['.next/**', 'out/**', 'node_modules/**', 'next-env.d.ts']),
+ globalIgnores(['.next/**', 'node_modules/**', 'next-env.d.ts']),
]);
diff --git a/next.config.mjs b/next.config.mjs
index 05c1535..1a67b9f 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,8 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
- output: 'export',
+ output: 'standalone',
+ turbopack: {
+ root: process.cwd(),
+ },
images: {
- unoptimized: true,
+ formats: ['image/webp'],
+ qualities: [60, 75, 85],
+ deviceSizes: [360, 390, 640, 768, 1024, 1280, 1440],
+ imageSizes: [32, 48, 64, 72, 96, 128, 192, 256, 320],
+ minimumCacheTTL: 86_400,
},
};
diff --git a/nginx.conf b/nginx.conf
deleted file mode 100644
index 2a3d94d..0000000
--- a/nginx.conf
+++ /dev/null
@@ -1,41 +0,0 @@
-server {
- listen 80;
- server_name _;
-
- root /usr/share/nginx/html;
- index index.html;
- charset utf-8;
- server_tokens off;
-
- gzip on;
- gzip_vary on;
- gzip_min_length 1024;
- gzip_types
- application/javascript
- application/json
- application/xml
- image/svg+xml
- text/css
- text/plain;
-
- add_header X-Content-Type-Options "nosniff" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
-
- location ^~ /_next/static/ {
- try_files $uri =404;
- add_header Cache-Control "public, max-age=31536000, immutable";
- add_header X-Content-Type-Options "nosniff" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- access_log off;
- }
-
- location / {
- try_files $uri $uri.html $uri/ =404;
- }
-
- error_page 404 /404.html;
-
- location = /404.html {
- internal;
- }
-}
diff --git a/package-lock.json b/package-lock.json
index c6549c5..87245c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"next": "^16.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "sharp": "0.35.4",
"three": "^0.180.0"
},
"devDependencies": {
@@ -614,7 +615,6 @@
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -2737,7 +2737,6 @@
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -5406,7 +5405,6 @@
"version": "7.8.5",
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5469,7 +5467,6 @@
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
diff --git a/package.json b/package.json
index 3107072..e158d7e 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"next": "^16.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "sharp": "0.35.4",
"three": "^0.180.0"
},
"devDependencies": {
diff --git a/public/fonts/IRANYekanXVF.ttf b/public/fonts/IRANYekanXVF.ttf
deleted file mode 100644
index c73c150..0000000
Binary files a/public/fonts/IRANYekanXVF.ttf and /dev/null differ
diff --git a/public/fonts/IRANYekanXVF.woff2 b/public/fonts/IRANYekanXVF.woff2
new file mode 100644
index 0000000..1e430a2
Binary files /dev/null and b/public/fonts/IRANYekanXVF.woff2 differ
diff --git a/public/fonts/Pacifico-LatinExt.woff2 b/public/fonts/Pacifico-LatinExt.woff2
new file mode 100644
index 0000000..aa42e0b
Binary files /dev/null and b/public/fonts/Pacifico-LatinExt.woff2 differ
diff --git a/public/fonts/Pacifico-OFL.txt b/public/fonts/Pacifico-OFL.txt
new file mode 100644
index 0000000..b8d0374
--- /dev/null
+++ b/public/fonts/Pacifico-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/scripts/deploy-release.sh b/scripts/deploy-release.sh
index e128355..5afa366 100644
--- a/scripts/deploy-release.sh
+++ b/scripts/deploy-release.sh
@@ -64,9 +64,9 @@ wait_until_healthy() {
local directory="$1"
local container_id status
- container_id="$(compose "$directory" ps -q static-site)"
+ container_id="$(compose "$directory" ps -q portfolio-app)"
if [[ -z "$container_id" ]]; then
- echo "The static-site container was not created." >&2
+ echo "The portfolio-app container was not created." >&2
return 1
fi
diff --git a/src/app/(en)/loading.tsx b/src/app/(en)/loading.tsx
new file mode 100644
index 0000000..9706c32
--- /dev/null
+++ b/src/app/(en)/loading.tsx
@@ -0,0 +1,5 @@
+import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
+
+export default function Loading() {
+ return