Source code for berktan.dev, the personal developer portfolio of Yasin Berktan Solmaz, a final year BEng Software Engineering student at the University of Westminster, London (2024 to 2027).
Built with the Next.js App Router, React 19, TypeScript, Tailwind CSS v3 and Framer Motion. The design goal is a very dark, high-craft, "quiet luxury" aesthetic in the spirit of Apple, Linear and Raycast: near-black background, a single lavender accent, translucent glass surfaces, and carefully tuned motion.
The site is self-hosted on a Node server behind a Cloudflare Tunnel rather than on a managed platform, so there are no platform-specific adapters or edge-only APIs anywhere in the codebase. See Self-hosting.
- Async React Server Component fetches the GitHub REST API (
/users/{username}/repos). - Cached with Next.js ISR (
revalidate: 3600, 1 hour). - Forks and archived repos are filtered out; results sorted by star count.
- Section wrapped in a
<Suspense>boundary so a slow GitHub response streams in without blocking the rest of the page. - Client-side re-sorting between Featured, Stars and Recent.
- Repos tagged with the
featuredGitHub topic rank first. - Cards render in an asymmetric bento grid.
- Server-only route handler (
/api/spotify) exchanges a refresh token for an access token and queries Spotify. - Client hook
useNowPlayingpolls every 10 seconds only while the browser tab is visible. - Each request uses an
AbortController; failed polls keep the last good payload. - Payload is a TypeScript discriminated union with four states:
unconfigured,idle,playing,recent. - Animated waveform in Spotify green when playing; neutral grey "Last played" label otherwise.
- Every failure path returns HTTP 200 with a degraded status. The widget is decorative and Spotify outages never surface as errors to visitors.
- Curated case studies live at
/workand/work/{slug}, each prerendered withgenerateStaticParams. dynamicParamsis disabled, so an unknown slug returns 404 without rendering anything.- Every page carries its own canonical URL, title, description, social card and structured data.
- The home page leads with the case study cards and filters any repo that already has one out of the live GitHub feed, so nothing appears twice saying less the second time.
- A live speech-to-subtitle demo served at
whisper.berktan.devfrom this same application. - Middleware rewrites the subdomain to the demo page and 308-redirects
/whisperon the apex across to it, so exactly one canonical URL exists rather than the same page answering on two hostnames. - The tunnel routes
/apion that hostname straight to the Python service, which makes the browser same-origin with the API: no CORS, no preflight, no origin allowlist to keep in step. - Upload progress uses
XMLHttpRequest, the only API that reports it, and job progress is aclip-pathreveal on a critically damped spring rather than an animatedwidth. - The API is a separate project: whisper-subtitle-generator.
- One metadata module builds every title, description, canonical URL, Open Graph block and Twitter card, so no route can drift from another.
ProfilePagewrapping aPersonon the home page,BreadcrumbListandCreativeWorkon case studies, andFAQPageon the recruiter questions.- FAQ structured data is generated from the same array the accordion renders, so a question can never be marked up with an answer the page does not display.
robots.tsandsitemap.tsare generated from the same route list the pages use.- Social cards are generated ahead of time by
npm run ogand committed as PNGs. See Social cards for why they are not a runtime route.
POST /api/contactvalidates every field server side, independently of the browser, and size-caps the body before parsing it.- Cloudflare Turnstile gates the send button, verified server side. Verification runs last of all the checks, since it is the only one making an outbound call, and is skipped entirely when no secret is configured.
- A hidden honeypot field returns a plain success rather than announcing that it was detected.
- In-memory sliding-window rate limiting, five messages per hour per client, keyed on
cf-connecting-ip. - Delivery through Resend, with
Reply-Toset to the sender so a reply goes straight back to them. - Missing credentials are a distinct state from a failed send, so the form can fall back to a prefilled
mailto:instead of implying the message went somewhere. - Success opens a real dialog: labelled, Escape to close, body scroll locked, focus moved in on open and returned to the submit button on close.
- Availability, right to work, location, stack and process, answered on the page instead of by email.
- Accordion expansion uses
grid-template-rowswith an opacity fade, scoped to one subtree and driven only by an explicit press. - Built as a button plus a labelled region with
aria-expandedandaria-controls, and collapsed instantly underprefers-reduced-motion.
- Preloader is pure CSS (not Framer Motion) because it plays while React is hydrating. The wordmark "BERKTAN" animates letter by letter on a 44 ms stagger, then the veil recedes with a combined scale, blur and opacity dissolve.
CinematicSectionscroll-linked wrapper uses Framer motion values (useScroll,useTransform) so scrolling never triggers a React re-render.MagneticDockItemimplements Apple-Dock magnetic hover: cursor position lives in auseMotionValueon the parent, item positions are measured once per hover session, and the effect is suppressed for non-mouse pointers and under reduced motion.SpotlightCursorpaints its gradient once and moves it withtranslate3d, compositor-only, with writes coalesced to one per animation frame.
- Colour tokens:
abyss #050506,surface #0e0e11/#131317, text tiers#f5f5f7/#9d9da7/#606069, accentglow #828fff/#5e6ad2. - Typography: Inter (variable) with Geist Mono for code.
- Four named easing curves shared between Tailwind and CSS custom properties:
out-strong,out-expo,in-out-strong,cinematic. - Surfaces avoid hard 1px borders. Layered inset shadows suggest a machined bezel instead.
- Background is a layered ambient field of drifting radial gradients, film grain and vignette.
- Full
prefers-reduced-motionsupport, plusprefers-reduced-transparencyandprefers-contrasthandling. - Tailwind's
future.hoverOnlyWhenSupportedcompiles everyhover:utility behind@media (hover: hover). - Animations restricted to
transform,opacityandfilter(compositor-only). - Server Components used wherever interactivity is not required.
- All secrets are server-only; no secret is ever prefixed with
NEXT_PUBLIC_. The Spotify access token never leaves the route handler. src/lib/url.tsis a single trust boundary that sanitises every external URL. URLs arriving from third-party APIs are validated tohttp:orhttps:in the fetch layer, preventing ajavascript:URL from being rendered into anhref.- The contact endpoint treats the browser as untrusted: types, lengths and the email pattern are all re-checked on the server, and provider errors are logged for the operator but returned to the client as a generic code, since they can name internal addresses and configuration.
cf-connecting-ipis trusted for rate limiting only because the origin is reachable exclusively through the Cloudflare Tunnel. Exposing the port directly would let a client set that header freely and the limiter would need replacing.- Analytics is cookieless, so the site sets nothing on a visitor's device and needs no consent banner. The Cloudflare beacon token is
NEXT_PUBLIC_because it is a site identifier rather than a credential; no secret is ever given that prefix.
DoomCard.tsxboots the 1993 DOOM shareware in the browser using the js-dos v8 emulator.- The game bundle is self-hosted in
public/and served same-origin, because the usual CDN sends no CORS headers. - It downloads only when the visitor clicks to boot it.
- The modal locks body scroll and closes on Escape.
Runtime dependencies
| Package | Version |
|---|---|
| next | ^15.5.24 |
| react | ^19.2.8 |
| react-dom | ^19.2.8 |
| framer-motion | ^11.11.9 |
| lucide-react | ^0.468.0 |
| resend | ^6.24.0 |
| sharp | ^0.35.4 |
| @radix-ui/react-slot | ^1.3.3 |
| class-variance-authority | ^0.7.1 |
| clsx | ^2.1.1 |
| tailwind-merge | ^3.6.0 |
Dev dependencies
| Package | Version |
|---|---|
| typescript | ^5 |
| tailwindcss | ^3.4.16 |
| eslint | ^9 |
| eslint-config-next | ^15.5.24 |
| satori | ^0.33.4 |
| postcss | ^8.4.49 |
| autoprefixer | ^10.4.20 |
| @types/node | ^22 |
| @types/react | ^19.2.18 |
| @types/react-dom | ^19.2.5 |
This project uses Tailwind CSS v3 (not v4) and the App Router (not the Pages Router).
1. Clone the repository
git clone https://github.com/AkoSuminoe/berktan-dev.git
cd berktan-dev2. Install dependencies
npm install3. Set up environment variables
cp .env.local.example .env.localFill in the values described in the table below.
4. Run the development server
npm run devOpen http://localhost:3000 in your browser.
Create a .env.local file in the project root. A .env.local.example is committed for reference.
| Variable | Required | Purpose |
|---|---|---|
GITHUB_USERNAME |
Yes | Which account the project feed reads from |
GITHUB_TOKEN |
No | Raises the GitHub API rate limit from 60 to 5000 requests per hour |
SPOTIFY_CLIENT_ID |
No | Spotify app client ID |
SPOTIFY_CLIENT_SECRET |
No | Spotify app client secret |
SPOTIFY_REFRESH_TOKEN |
No | OAuth refresh token |
RESEND_API_KEY |
No | Contact form delivery. Without it the endpoint reports unconfigured and the form falls back to mailto: |
CONTACT_FROM |
No | Sender address, for example Portfolio <noreply@send.example.com>. Required alongside RESEND_API_KEY |
CONTACT_TO |
No | Recipient. Defaults to the address in site-config.ts |
NEXT_PUBLIC_CF_BEACON_TOKEN |
No | Cloudflare Web Analytics. Absent means no beacon is rendered at all |
NEXT_PUBLIC_TURNSTILE_SITE_KEY |
No | Captcha widget. Absent means no widget and no gate |
TURNSTILE_SECRET |
No | Verifies captcha tokens. Absent means verification is skipped |
NEXT_PUBLIC_WHISPER_API_URL |
No | Empty in production, since the tunnel makes the demo API same-origin. http://localhost:8000 in development |
If the three Spotify variables are absent, the widget renders nothing at all and the rest of the site works normally.
Every NEXT_PUBLIC_ value is read at build time, not at start time, because they are inlined into the output. Setting one before npm start without rebuilding leaves the feature silently absent. This applies to the beacon token, the Turnstile site key and the Whisper API URL alike.
A Turnstile site key is public by design: Cloudflare expects it in the HTML of every page carrying a widget and it grants nothing on its own, which is why the NEXT_PUBLIC_ prefix is correct for it and never for the secret. One Turnstile site can cover several hostnames, so the apex and the demo subdomain can share a key pair; if they do, TURNSTILE_SECRET here must match the one in the Whisper server's environment.
If you configure Resend, verify a subdomain rather than the root domain. A domain already using a mail router such as Cloudflare Email Routing publishes an SPF record at the root, and a second SPF TXT record on the same name is a permanent error that breaks inbound and outbound delivery at once. Verify something like send.example.com, send from there, and leave the root records untouched.
The refresh token needs two scopes: user-read-currently-playing and user-read-recently-played. Spotify credentials come from the Spotify developer dashboard.
src/
app/
layout.tsx root layout, fonts, ambient background layers, metadata
page.tsx home page composition
not-found.tsx custom 404
globals.css design tokens, glass surfaces, preloader, ambient field
robots.ts generated robots.txt
sitemap.ts generated sitemap.xml
opengraph-image.png committed social card, picked up by file convention
api/contact/route.ts validated, rate-limited contact endpoint
api/spotify/route.ts server-only Spotify proxy
work/page.tsx case study index
work/[slug]/page.tsx individual case study
privacy/page.tsx privacy policy
tokyo/page.tsx private sub-page
components/
Hero.tsx landing hero with scroll-linked exit
About.tsx
SkillsMarquee.tsx infinite CSS ticker of skills
Experience.tsx work history plus education
Projects.tsx Server Component, case studies plus GitHub feed
ProjectsGrid.tsx client-side sorting and the bento grid
ProjectCard.tsx individual repo card
CaseStudyCard.tsx shared card for the home page and /work
Breadcrumbs.tsx trail plus matching structured data
Faq.tsx recruiter FAQ accordion
Contact.tsx contact form, posts to the API
ContactSuccessModal.tsx success dialog
LondonTime.tsx live London clock and coordinates
MobileContactFab.tsx mobile-only contact action
SocialLinks.tsx shared social link variants
JsonLd.tsx structured data emitter
Analytics.tsx Cloudflare Web Analytics beacon
GodModeEgg.tsx 404 easter egg
Footer.tsx
FloatingDock.tsx fixed bottom navigation dock
MagneticDockItem.tsx Apple-Dock style magnetic hover
NowPlaying.tsx Spotify widget
Preloader.tsx homepage intro veil
SpotlightCursor.tsx cursor-following light
CinematicSection.tsx scroll-linked section wrapper
GlassCard.tsx reusable glass surface
DoomCard.tsx DOOM easter egg
Providers.tsx Framer MotionConfig wrapper
tokyo/ components for the private sub-page
ui/ small shadcn-style primitives (button, card)
hooks/
useNowPlaying.ts polling hook for the Spotify widget
lib/
github.ts GitHub REST client, ISR cached
spotify.ts shared Spotify types and mappers
url.ts URL sanitisation trust boundary
seo.ts titles, descriptions, canonicals, keywords
jsonld.ts structured data builders and serialiser
case-studies.ts case study content
faq.ts recruiter questions and answers
site-config.ts all personal content in one typed file
tokyo-itinerary.ts data for the private sub-page
utils.ts cn() class merge helper
assets/
Inter-SemiBold.ttf used only by the social card generator
scripts/
generate-og.mjs renders the committed social cards
public/
og/ generated social cards for /work and case studies
doom.jsdos self-hosted DOOM shareware bundle
Personal content lives in typed data files rather than being scattered through components:
| File | Holds |
|---|---|
src/lib/site-config.ts |
Name, bio, email, location, social links, work experience, education, skills |
src/lib/case-studies.ts |
Case study copy. Adding an entry creates its route, its sitemap row and its social card |
src/lib/faq.ts |
Recruiter questions, which also become the FAQPage structured data |
site-config.ts is the origin for the canonical URL, the contact address and the schema, so changing an address there updates the Person schema, the social row, the form fallback and the API recipient at once.
Two fields ship as null on purpose. cvUrl gates the CV call to action, so the link cannot exist before the file does, and responseTime gates the reply-time note beside the form, so no promise is shown until there is one to make.
The deployment target is a plain Node server, not a managed platform:
npm ci
npm run build
npm startnext.config.js is configured for that. X-Powered-By is removed, four security response headers are set, and the optimised-image cache TTL is raised from the 60 second default to seven days so a small origin is not re-optimising the same remote images all day. HSTS is deliberately left to the CDN, where it can be switched off again, rather than baked into origin responses where a browser caches it for the full max-age.
sharp is a runtime dependency because self-hosted image optimisation requires it.
Both hostnames are served by this one application, split inside the Cloudflare Tunnel. Path rules are evaluated in order, so the API rule has to come first:
ingress:
- hostname: whisper.berktan.dev
path: ^/api/.*
service: http://localhost:8000
- hostname: whisper.berktan.dev
service: http://localhost:3000
- hostname: berktan.dev
service: http://localhost:3000Chrome rendered by the root layout needs care under this arrangement. Every dock and footer navigation href is a /#section fragment, and a fragment never reaches the server, so on the subdomain those requests arrive as / and would land on the demo with a hash matching nothing. useApexOrigin resolves the correct origin in the browser, because middleware cannot tell the two cases apart. It returns an empty string during server rendering so hydration matches; reading the Host header instead would make the root layout dynamic and opt the whole site out of static generation.
Cards are generated by npm run og and committed, rather than rendered by an opengraph-image.tsx route. next/og is unusable on Windows in Next 15: its bundled loader calls path.join() on import.meta.url, which rewrites the file:// separators, and the fileURLToPath() that follows throws Invalid URL, failing the build. Pre-generating removes the runtime dependency entirely, and the generator reads the case study list directly, so a new entry produces its card without the script being touched.
Run npm run og after changing a name, a title or a case study, then commit the result.
| Script | Command |
|---|---|
npm run dev |
next dev |
npm run build |
next build |
npm run start |
next start |
npm run lint |
next lint |
npm run og |
node scripts/generate-og.mjs |
The source code in this repository is released under the MIT License.
Two things the MIT grant does not cover:
public/doom.jsdosis the 1993 DOOM shareware episode, which remains the property of id Software and is redistributed under its own shareware terms.- The written content, biography and personal branding are not part of the code grant. If you reuse this project, replace
src/lib/site-config.tswith your own details.