
Agency Website

Corporate website designed and developed for the web development agency I co-founded. Showcases our digital solutions and services.
Our own agency site had to be the proof of the work we were selling: if we tell a client to harden their contact form, ours had better be hardened. The real issue was that the single write path here β a publicly open contact endpoint β has no authentication: there is no session, and therefore no server-side context about who sent the request. Bot traffic, double submission, header injection and a flood from one source all had to be handled at that one endpoint. The second issue was legal: a site selling into Germany falls under the GDPR, which treats the IP as personal data β and rate limiting rests on exactly that IP.
Three separate contexts in three languages. German-speaking visitors are the primary target: they look for Impressum, Datenschutz and AGB pages and expect price transparency. Turkish visitors look at service scope and references. English-speaking visitors sit between the two. The fourth user is us: we want to see incoming requests in our inbox without drowning in spam, and to publish a blog post by committing it to the repository rather than logging into a panel.
Sole developer. Trilingual information architecture, localized route design, markdown content pipeline, the security layers of the contact endpoint, email templates, consent-gated analytics, security headers and deployment.
Next.js 16 App Router, no database. Content comes from two sources: blog posts from content/blog/<slug>/<locale>.md files, portfolio and reference copy from JSON files under content/. Both are read at build time; the JSON side is validated with Zod, and a malformed locale file falls back to an empty set through safeParse rather than taking the whole build down. Markdown is parsed with gray-matter, converted to HTML with marked, and printed only after passing two layers of sanitization. The three languages (de/tr/en) are published with a prefix on every route, and path names are localized: /leistungen, /hizmetler and /services are three faces of the same route. The only write path is /api/contact; a request passes through IP extraction and hashing, rate limiting, Zod validation, header-injection rejection, an idempotency check, honeypot, CSRF and Turnstile before handing two emails to Resend. As a third conversion path, a Cal.com booking embed renders only when its environment variable is set.
In the first version the limit was held in an in-process Map. On serverless that makes the limit meaningless: each instance carries its own counter, so anyone sending enough parallel requests never sees the ceiling, and the Map kept growing for the lifetime of the container. The counter moved to Upstash Redis with a sliding window of 5 requests per minute per IP. If the Upstash call errors the gates do not open completely: the request falls through to the in-memory counter, and that Map is now swept of expired entries every five minutes.
Trade-offEvery form submission now pays an extra network round trip to an external service, and the site depends on a third-party provider even when its own infrastructure is healthy. When the fallback engages the protection is not real protection: the limit is per-instance again, meaning it degrades to its weakest form exactly when it is needed most. There is also carried weight β keeping the fallback path alive requires a cleanup timer running for the process lifetime and two separate code paths to maintain.
The IP is only a key here: it identifies the same client for rate limiting and idempotency, not information worth keeping. Under the GDPR's data minimization principle the IP is passed through SHA-256 with a per-process salt and only the digest is written to the maps; the raw value never leaves the request scope. The second decision is sharper: if neither x-real-ip nor x-forwarded-for is present, the request is rejected with a 400. The alternative, a shared "unknown" bucket, would let one bad actor exhaust the limit for everyone arriving without those headers.
Trade-offLegitimate clients that carry no IP header β some proxy chains, server-side clients, certain testing tools β cannot submit the form at all, and the error message does not tell them what to do about it. Because the salt lives only as long as the process, a recycled container gives the same IP a new digest and resets its counter in the in-memory fallback: the price of the privacy gain is that the limit can reset precisely during a fallback.
Blog posts are committed to the repository, so authors are trusted and output is printed with dangerouslySetInnerHTML. Even so, marked's output passes first through a cheap regex sweep (script/style blocks, on* attributes, javascript: and data:text/html URLs) and then through DOMPurify. The reasoning is that the author is not the threat model: a hostile pull request, or a compromised dependency that smuggles HTML into markdown, invalidates the trusted-author assumption in one step.
Trade-offTwo separate passes run on every post render, and isomorphic-dompurify loads jsdom on the server β not a light dependency for a small content pipeline. The more annoying cost is on the authoring side: DOMPurify silently strips legitimate markup outside its allowlist (embedded frames, custom attributes). The author sees no warning about why their HTML disappeared and has to check the output by eye.
There is no session on the site, so there is no server-side state in which to hold a token for comparison. /api/csrf generates a random token, returns it in the response body and writes it as a SameSite=Strict, Secure cookie; the form sends both and the server verifies the match with a constant-time comparison. A cross-origin page cannot read that cookie and therefore cannot forge the body value.
Trade-offBecause the cookie has to be readable by JavaScript it cannot be HttpOnly. That also defines the layer's boundary: if an XSS lands on the site the token can be read directly, so this mechanism stops CSRF and says nothing about XSS. It also requires an extra network request before the form can be shown; if that request fails the user cannot submit at all, and all they see is a "please refresh" message.
No persistence layer was built because nothing required one: content lives in the repository and the only write path is sending an email. Keeping content in the file system delegates version history and language parity to git itself β if one of a post's three languages is missing, it shows up in the diff. Upstash was chosen because, for a single counter, it is the cheapest way to hold distributed state without operating a server; we store no durable data, only the sliding window. Zod is the single validation source for both the contact request and the JSON content files: the first produces a 400 at runtime, the second catches malformed content at build time. Resend and React Email let requests land in an inbox that is already open β better than writing a panel nobody will log into.
Ten HTTP headers are defined: HSTS (two years, including subdomains, preload-eligible), framing denial, nosniff, referrer policy, Permissions-Policy, X-Permitted-Cross-Domain-Policies, COOP, CORP and CSP. The CSP is enforced in production; object-src is 'none', base-uri and form-action are 'self', frame-ancestors is 'none', the http scheme is closed in img-src and upgrade-insecure-requests is on β 'unsafe-eval' is granted only in development. On the contact endpoint the layers run in order: salted IP digest, Upstash sliding-window limit, Zod schema validation including length limits, rejection of fields containing CR/LF/NUL β which closes header smuggling for the email field that reaches the replyTo header β a double-submission short circuit via idempotency key, honeypot, double-submit CSRF and Turnstile. Turnstile was previously disabled "temporarily", letting any client that omitted the token bypass the CAPTCHA; it is now strictly enforced whenever the key is configured. Text fields are stripped of tags and then HTML-encoded. Third-party calls are bounded by AbortController and Promise.race timeouts, mutation responses are no-store, and error bodies leak no internal detail. Analytics loads only after cookie consent is given.
Animation and carousel packages were opened to tree shaking via optimizePackageImports β lucide-react, framer-motion, lenis, Turnstile and the two Embla packages. Immutable assets under /images and /fonts carry a one-year immutable cache header. Content JSON is memoized at module level keyed by (directory, locale): the same file was being read on more than ten routes per language. Email templates and both sends run in parallel, with no sequential waterfall. @next/bundle-analyzer sits in the repo as a separate command. No stored, date-stamped Lighthouse output exists for this project, so no page speed score is written here.
I would have enabled Turnstile in strict mode from the very beginning. A security control commented out "temporarily" does not stay temporary; the form's primary bot defense ran at the client's discretion for a stretch of time nobody noticed. If a control has to be relaxed, that relaxation needs an expiry date. Second, I would rethink the rate limiter's fallback: the in-memory Map gives the feeling of protection when Upstash is down, but on serverless it does not provide it β at Bergaz GΔ±da I solved the same problem by writing limits to the database, because there was already a database there. Here the right answer is probably to fail closed rather than open on fallback: temporarily closing the form when Upstash is unavailable beats leaving it open and unprotected. Third, on the content side: blog markdown is not validated the way the Zod-checked JSON is, and frontmatter fields fall back to empty strings. In this portfolio, validating project content in MDX frontmatter against a schema breaks the build and surfaces the error immediately; the same discipline belonged on the blog side.
Tell me what you want to build; I'll tell you up front how long it takes and where to start.