<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ahmet Ulutaş</title>
    <description>The latest articles on DEV Community by Ahmet Ulutaş (@ahmet_uluta_237c3ef2fc6c).</description>
    <link>https://dev.to/ahmet_uluta_237c3ef2fc6c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4237833%2F882a1882-c920-4dfa-a758-9bc34f2cab62.png</url>
      <title>DEV Community: Ahmet Ulutaş</title>
      <link>https://dev.to/ahmet_uluta_237c3ef2fc6c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmet_uluta_237c3ef2fc6c"/>
    <language>en</language>
    <item>
      <title>How I got ~500k organic search clicks with a vibe-coded app: the architecture, phase by phase</title>
      <dc:creator>Ahmet Ulutaş</dc:creator>
      <pubDate>Tue, 10 Nov 2026 20:30:11 +0000</pubDate>
      <link>https://dev.to/ahmet_uluta_237c3ef2fc6c/how-i-got-500k-organic-search-clicks-with-a-vibe-coded-app-the-architecture-phase-by-phase-48hp</link>
      <guid>https://dev.to/ahmet_uluta_237c3ef2fc6c/how-i-got-500k-organic-search-clicks-with-a-vibe-coded-app-the-architecture-phase-by-phase-48hp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.ahmetulutas.com/en/blogs/a-story-on-how-i-got-500k-organic-users-with-ai" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A note before starting: this post is not written by AI, but the diagrams were generated with AI.&lt;/p&gt;

&lt;p&gt;Since the introduction of early AI tools, I have been really into using them actively, both for my professional career and in my free time, to try to catch up with their evolution (which, as everybody here knows, is pretty hard to do). As a result, I vibe-coded a web application (100% vibe-coded) for astrological calculations. Bear in mind I have zero knowledge of astrological math and calculations, so I should give AI tools good credit for that.&lt;/p&gt;

&lt;p&gt;Initially, Cursor was my IDE to vibe-code and Claude was the primary LLM to write the code. As the application got real users I had to roll up my sleeves, since this app needed to scale and AI felt short on architectural decisions. Plus, as you know, I'm obsessed with web performance, so I wanted to see how far I can go combining my skills with AI.&lt;/p&gt;

&lt;p&gt;This post dives deep into this project's architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Early architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Technical
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjw7y6k3ftbq2p29197rd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjw7y6k3ftbq2p29197rd.png" alt="Early technical architecture: a Next.js app with client-side calculations, an open-source places API for location search and Supabase for feedback" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Early technical architecture&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The app was running on a fully Next.js platform with client-side calculation methods, an open-source places API for location search, and user comments and feedback saved into a free-tier Supabase. A user types in their information, client-side calculations produce the result, the user gets it and leaves. Sounds smooth and easy. It was mostly vibe-coded: I set up my Cursor files very well, and the skills and commands work so well that I one-shot each new calculator page. Life is good.&lt;/p&gt;

&lt;h3&gt;
  
  
  Content
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnm7y3ued2gdq5c5d9uyb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnm7y3ued2gdq5c5d9uyb.png" alt="Early content architecture: AI writes content, Sanity Studio stores it, a webhook triggers a fresh build" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Early content architecture&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For content I use the Sanity Studio free-tier plan, and it's enough because all of my pages are SSG'ed (statically generated at build time, so I query Sanity only when my Next.js app rebuilds). AI writes my content, I publish it, a Sanity webhook triggers my deploy, and I get a fresh build with fresh content. Sanity Studio is cloud-hosted by Sanity, which is included in the free plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: ~5K monthly users
&lt;/h2&gt;

&lt;p&gt;The app is running on the free-tier Vercel plan and nothing breaks.&lt;/p&gt;

&lt;p&gt;Another challenge at this step was the free places API's limits. For an astrological calculation app, location search is essential: location input is required for almost every calculation. I solved this with Redis (Upstash) for my place searches, so I didn't query the free API that often. It solved my problem at this step, but Upstash Redis itself has a free-tier limit too, just harder to hit than the location API's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdocx7kn8xqawjade85e8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdocx7kn8xqawjade85e8.png" alt="Phase 1 architecture: Redis in front of the places API" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Phase 1 architecture&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I decided to add more pages to grow my audience, and as the audience grew I wanted to move to a more professional setup. It was not a must in this phase, but a preparation for future traffic spikes. So I moved my app to AWS Amplify, with CloudFront for security.&lt;/p&gt;

&lt;p&gt;This is also the phase where I started to dive deep into the app's performance. I constantly analyzed the bundle size, optimized the codebase, and ran dead code detection (knip is very useful). I added a JavaScript performance observer that logs users' metrics to my Supabase database, and built a dashboard on that data to monitor user metrics, rather than using ready-made tools like Sentry or New Relic. Monitoring RUM (real user metrics) will surprise you, as you will clearly see that users do not have fast devices :) And for ranking higher on Google you need to serve a good user experience, so it became an obsession at this point. I simplified all the components, removed heavy UI component libraries, lazy-loaded the birth chart library to get it out of the bundle, and so on. It's also hard to believe how many users are still on very slow devices.&lt;/p&gt;

&lt;p&gt;Web performance is not something you can focus on, solve and forget. It is something you need to consider and monitor constantly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8akhp0yjsc40dlbvvfqg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8akhp0yjsc40dlbvvfqg.png" alt="Search Console Core Web Vitals report for mobile: 229 URLs rated good, none poor or needing improvement" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Google Search Console, Core Web Vitals report for mobile: 229 URLs rated good, none poor or needing improvement.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: ~10K monthly users
&lt;/h2&gt;

&lt;p&gt;AWS Amplify ran smoothly, but the Lambda functions and CloudFront started to get costly, and I have no monetization plan other than Google AdSense. I didn't want to give it all back to AWS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6k61ft5zkng4vi4ps17t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6k61ft5zkng4vi4ps17t.png" alt="Phase 2 architecture on AWS Amplify and CloudFront" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Phase 2 architecture&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Another challenge with AWS: I had published more than ~200 pages to this application for organic SEO, and I hit the wall of bundle size. Platforms like AWS Amplify and Vercel only allow a bundle of 200 MB, and since I'm SSG'ing all my pages, my bundle grew larger than that. So I had to find a way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: ~30K monthly users
&lt;/h2&gt;

&lt;p&gt;I decided to move away from AWS and host the app on an Ubuntu VPS with Coolify, so the bundle size limit disappeared. Now I can statically generate as many pages as I want. Plus, it's a lot cheaper.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvclh4u1t1frppke1pqeu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvclh4u1t1frppke1pqeu.png" alt="Cloudflare Web Analytics for burcapp.com over the last 24 hours: 1.54k visits, 3.92k page views, 97% of LCP measurements good" width="799" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cloudflare Web Analytics for the project, last 24 hours, bots excluded.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For caching I use Cloudflare, which offers a generous quota, and I cache all the requests hitting my VPS aggressively, which has also improved my Lighthouse metrics. I have a cache purge method that runs against Cloudflare whenever a new deploy or a content change happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkjpovq7lyo0hpg5eqisl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkjpovq7lyo0hpg5eqisl.png" alt="Phase 3 architecture at 30k users: VPS with Coolify behind Cloudflare" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Phase 3 architecture, 30k users&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I added a user review form to the application to get feedback from users. Very often I got messages saying the calculations were giving wrong results. In the end it is vibe-coded, and you know how it is. The next challenge at this point was the inconsistency of the astrological results. I did some research and found the issue. Remember that our calculations were done in the client's browser with client-side JavaScript. Therefore the calculations differed from device to device, and the time zone handling was not consistent.&lt;/p&gt;

&lt;p&gt;Another reason was that there are a lot of libraries that provide planets' positions, and the one I had been using was neither very popular (there are different techniques to calculate the result, and some of them are more popular and preferred) nor used by professional astrological calculators.&lt;/p&gt;

&lt;p&gt;Plus, at this point I had decided to create a mobile app for the app stores, and I wanted web and mobile to give the same results. Client-side calculations weren't enough for a consistent app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: an API for my web and mobile apps
&lt;/h2&gt;

&lt;p&gt;Professional planet-position libraries are licensed. But luckily there is a good Python community that provides the same data as open source, so I deployed a Python API. Only my Node.js server can query it. I got 98% correct calculations when compared to professional paid libraries. And with my own API I can now provide correct calculations to my multi-platform apps.&lt;/p&gt;

&lt;p&gt;A strict orchestration of Claude helped me develop this robust and precise API. I now have an orchestration workflow all the way from planning to UAT testing.&lt;/p&gt;

&lt;p&gt;Another benefit of separating the calculations into their own API is that my Next.js app's INP scores have remarkably healed, as it has become less dependent on browser scripting.&lt;/p&gt;

&lt;p&gt;You can view the site at &lt;a href="https://burcapp.com/" rel="noopener noreferrer"&gt;burcapp.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpzxinudgoiq1fycqe87.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpzxinudgoiq1fycqe87.png" alt="Phase 4 architecture: web and mobile apps call a Node.js server, which is the only client of the Python astrology API" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Phase 4 architecture, API&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finally
&lt;/h2&gt;

&lt;p&gt;It has been a good side project that taught me a lot about vibe-coding and about handling a web application with real users end to end. I started this project to practice with AI 15 months ago. Back then I used Cursor for coding and now I'm on Claude. The LLMs I have been using have gotten remarkably better since then.&lt;/p&gt;

&lt;p&gt;Take the &lt;a href="https://github.com/AgricIDaniel/claude-seo" rel="noopener noreferrer"&gt;claude-seo skill&lt;/a&gt; for instance. It is a very powerful tool to quickly scan your pages across many different aspects of SEO, report what you are missing, and help you improve your organic traffic.&lt;/p&gt;

&lt;p&gt;I have learned a lot about AI from this project. Looking back at where I started, I have come a long way: I learned how to orchestrate agents, create rules and commands, when to use skills and when to delete rules, and so on. I came to this conclusion:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffx33m35och2wzsg4ehu6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffx33m35och2wzsg4ehu6.png" alt="Google Search Console, last 12 months of web search: 481K clicks, 4.23M impressions, 11.4% average click-through rate, average position 7.6" width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Google Search Console result for the last 12 months: 481K clicks and 4.23M impressions. The interface is in Turkish, where "B" means thousand and "Mn" means million.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can't vibe-code an app and expect people to use it without giving a thought to what it is really building. However, if you know what you are doing, AI is a very powerful tool to improve productivity, and its outcome is getting much better every day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>nextjs</category>
      <category>seo</category>
    </item>
    <item>
      <title>A birth time is a wall clock, not an instant: how one timezone bug moves a rising sign</title>
      <dc:creator>Ahmet Ulutaş</dc:creator>
      <pubDate>Tue, 10 Nov 2026 20:15:45 +0000</pubDate>
      <link>https://dev.to/ahmet_uluta_237c3ef2fc6c/a-birth-time-is-a-wall-clock-not-an-instant-how-one-timezone-bug-moves-a-rising-sign-36nl</link>
      <guid>https://dev.to/ahmet_uluta_237c3ef2fc6c/a-birth-time-is-a-wall-clock-not-an-instant-how-one-timezone-bug-moves-a-rising-sign-36nl</guid>
      <description>&lt;p&gt;If you turn "14:00 on 15 July 1990" into a &lt;code&gt;Date&lt;/code&gt; on whatever device runs your code, you get a different instant on every device. For an astrology calculator that is not a rounding problem: the same birth data produced four different rising signs. The fix is to store the wall-clock reading together with the IANA zone of the birth place, and to convert on the server, never from the runtime's own zone.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://burcapp.com" rel="noopener noreferrer"&gt;Burcapp&lt;/a&gt;, a free set of Turkish astrology calculators, and this bug is why birth time now travels through the whole app as a wall clock plus a zone. Here is the whole thing with the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A wall clock is not an instant
&lt;/h2&gt;

&lt;p&gt;A birth certificate says "14:00". That is a reading on a clock at the birth place. It becomes an instant only once you know which zone that clock was in on that date.&lt;/p&gt;

&lt;p&gt;JavaScript's &lt;code&gt;Date&lt;/code&gt; parser treats a date-time string without an offset as local time, meaning the local time of the device that runs it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"1990-07-15T14:00:00"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running that with &lt;code&gt;TZ&lt;/code&gt; set to four different zones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device zone&lt;/th&gt;
&lt;th&gt;Instant (UTC)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Europe/Istanbul&lt;/td&gt;
&lt;td&gt;1990-07-15T11:00:00.000Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC&lt;/td&gt;
&lt;td&gt;1990-07-15T14:00:00.000Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;America/New_York&lt;/td&gt;
&lt;td&gt;1990-07-15T18:00:00.000Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asia/Tokyo&lt;/td&gt;
&lt;td&gt;1990-07-15T05:00:00.000Z&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A spread of 13 hours for one birth. If that code runs in the browser, the answer depends on where the visitor lives. If it runs on your server, it depends on the server's zone, which says nothing about the birth place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does to a chart
&lt;/h2&gt;

&lt;p&gt;I sent the same birth, 14:00 on 15 July 1990 in Istanbul (41.0082 N, 28.9784 E), to our calculation service four times. Only the zone used to read "14:00" changed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;"14:00" read as&lt;/th&gt;
&lt;th&gt;Rising sign (Ascendant)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Europe/Istanbul&lt;/td&gt;
&lt;td&gt;Libra 29°10'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC&lt;/td&gt;
&lt;td&gt;Sagittarius 4°33'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;America/New_York&lt;/td&gt;
&lt;td&gt;Aquarius 1°51'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asia/Tokyo&lt;/td&gt;
&lt;td&gt;Leo 18°10'&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four zones, four signs. Only the first is correct, and it sits 50 arcminutes before the Scorpio boundary. The Ascendant sweeps the whole zodiac in a day, about one degree every four minutes on average, so a clock error of a few minutes can flip even the correct zone's answer. That is why a rising-sign calculator has to be exact about the instant in a way a horoscope-by-month page never was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offset is not a property of the city
&lt;/h2&gt;

&lt;p&gt;The usual shortcut is "Istanbul is UTC+3". It is now, and the rows below show it was not always. The service resolves the offset from IANA tzdata for the exact date. Same city, same clock reading at noon:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Europe/Istanbul offset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1970-07-01&lt;/td&gt;
&lt;td&gt;+02:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1985-01-15&lt;/td&gt;
&lt;td&gt;+02:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1985-07-15&lt;/td&gt;
&lt;td&gt;+03:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2016-12-15&lt;/td&gt;
&lt;td&gt;+03:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fixed offset per city would be wrong in half of these rows. You need the zone name and the date, and a library that knows the history. For births before 1970, tzdata is best-effort, so the zone field has to stay user-editable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Never build the instant from the runtime's zone. Carry &lt;code&gt;{ date, time, zone }&lt;/code&gt; as data and convert with the zone you were given. With plain &lt;code&gt;Intl&lt;/code&gt;, no dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Offset of `zone` at a given instant, in minutes.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;offsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instantMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"en-US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;hourCycle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"h23"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"numeric"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2-digit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2-digit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2-digit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2-digit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2-digit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatToParts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instantMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;asUtc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asUtc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instantMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wallClockToInstant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mo&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Pretend the wall clock is UTC, then correct by the zone's offset.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;offsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;guess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// The offset can differ at the corrected instant (a DST edge): take it once more.&lt;/span&gt;
  &lt;span class="n"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;offsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it for 14:00 on 15 July 1990 with each zone and run it under any &lt;code&gt;TZ&lt;/code&gt;: the &lt;code&gt;Europe/Istanbul&lt;/code&gt; call returns 11:00Z, &lt;code&gt;UTC&lt;/code&gt; 14:00Z, &lt;code&gt;America/New_York&lt;/code&gt; 18:00Z and &lt;code&gt;Asia/Tokyo&lt;/code&gt; 05:00Z every time, because the runtime's zone is no longer an input.&lt;/p&gt;

&lt;p&gt;Two honest limits. On the night a clock jumps forward, a wall-clock time can not exist; on the night it falls back, it happens twice. This function picks one answer silently. A product that cares should ask the user which one. And if a ready-made &lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt;, Luxon or &lt;code&gt;date-fns-tz&lt;/code&gt; is available to you, use it rather than this sketch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Better: do not convert in the browser at all
&lt;/h2&gt;

&lt;p&gt;Our forms send the wall clock and the zone as they are, and the calculation service turns them into an instant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"birthDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"1990-07-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"birthTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"14:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;41.0082&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;28.9784&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Europe/Istanbul"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zone is filled in from the chosen city, and it stays editable. If no zone can be found, the service says so in the response and the page shows a warning, instead of quietly using the visitor's device zone.&lt;/p&gt;

&lt;p&gt;Coordinates and the zone do two different jobs. The coordinates say where the observer stands, which sets the Ascendant and the house cusps. The zone says what the clock reading means. Getting one right does not get the other right, so every request carries both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it the boring way
&lt;/h2&gt;

&lt;p&gt;The check I rely on: open the same result link in a browser set to &lt;code&gt;Europe/Istanbul&lt;/code&gt;, then &lt;code&gt;UTC&lt;/code&gt;, then &lt;code&gt;America/New_York&lt;/code&gt;. The page must render identically. In Node, run the test suite three times with &lt;code&gt;TZ=Europe/Istanbul&lt;/code&gt;, &lt;code&gt;TZ=UTC&lt;/code&gt; and &lt;code&gt;TZ=America/New_York&lt;/code&gt; and compare the output.&lt;/p&gt;

&lt;p&gt;I wrote up how the service computes positions, and what it checks them against, on the &lt;a href="https://burcapp.com/hesaplama-yontemi" rel="noopener noreferrer"&gt;Burcapp calculation methodology page&lt;/a&gt;. The birth chart calculator that uses this flow is &lt;a href="https://burcapp.com/dogum-haritasi-hesaplama" rel="noopener noreferrer"&gt;Doğum Haritası Hesaplama&lt;/a&gt; (Turkish).&lt;/p&gt;

&lt;p&gt;Disclosure: I build Burcapp. It is free and needs no sign-up.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>datetime</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
