<?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: Tanin Na Nakorn</title>
    <description>The latest articles on DEV Community by Tanin Na Nakorn (@tanin47).</description>
    <link>https://dev.to/tanin47</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3691146%2F65d4126e-696a-41bc-859e-b723c34e041f.jpeg</url>
      <title>DEV Community: Tanin Na Nakorn</title>
      <link>https://dev.to/tanin47</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tanin47"/>
    <language>en</language>
    <item>
      <title>How to set up many landing pages with waitlist in an economical way</title>
      <dc:creator>Tanin Na Nakorn</dc:creator>
      <pubDate>Tue, 13 Jan 2026 11:09:12 +0000</pubDate>
      <link>https://dev.to/tanin47/how-to-set-up-many-landing-pages-with-waitlist-in-an-economical-way-2fn8</link>
      <guid>https://dev.to/tanin47/how-to-set-up-many-landing-pages-with-waitlist-in-an-economical-way-2fn8</guid>
      <description>&lt;p&gt;I have been launching several landing pages in the past month. All of them need a waitlist functionality where visitors can sign up to be in a waitlist.&lt;/p&gt;

&lt;p&gt;One way is to deploy a full-stack app with frontend and backend where the backend connects to a newsletter service like &lt;a href="https://buttondown.com/" rel="noopener noreferrer"&gt;Buttondown&lt;/a&gt;. However, hosting a website with a backend is more expensive than hosting a static website with no backend. With a lot of landing pages, that gets a bit expensive.&lt;/p&gt;

&lt;p&gt;If there’s no backend, we can host it for FREE on Netlify or GitHub Pages.&lt;/p&gt;

&lt;p&gt;Then, I learned about CORS (Cross-Origin Resource Sharing), which is a mechanism that allows you to invoke &lt;code&gt;fetch(..)&lt;/code&gt; to a different domain. Since &lt;code&gt;fetch(..)&lt;/code&gt; goes to a different domain, this means the current domain that hosts your landing page doesn’t need a backend. This means you can host many landing pages on Netlify or GitHub Pages and invoke &lt;code&gt;fetch(..)&lt;/code&gt; to a domain that hosts a waitlist backend.&lt;/p&gt;

&lt;p&gt;On the storage side, we can write to Google Sheets, which is free and the final destination for my waitlist emails anyway.&lt;/p&gt;

&lt;p&gt;I’ve made an open-sourced self-hostable waitlist backend here: &lt;a href="https://github.com/tanin47/wait" rel="noopener noreferrer"&gt;https://github.com/tanin47/wait&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two example landing pages are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://backdooradmin.app" rel="noopener noreferrer"&gt;https://backdooradmin.app&lt;/a&gt; — Backdoor is a self-hosted database editing tool for your team; no need to build admin dashboard anymore.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://backgroundtime.com" rel="noopener noreferrer"&gt;https://backgroundtime.com&lt;/a&gt; — Automated time tracking for lawyers. No click!&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The high-level architecture looks like below:&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.amazonaws.com%2Fuploads%2Farticles%2Fgvo7x1g6mhzpelflikhj.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.amazonaws.com%2Fuploads%2Farticles%2Fgvo7x1g6mhzpelflikhj.png" alt=" " width="786" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement CORS
&lt;/h2&gt;

&lt;p&gt;Most modern browsers, if not all, support CORS.&lt;/p&gt;

&lt;p&gt;When a browser detects that &lt;code&gt;fetch(..)&lt;/code&gt; goes to a different domain, it would first send a HTTP request whose method is &lt;code&gt;OPTIONS&lt;/code&gt; to that path. Your waitlist backend needs to respond with the below CORS-related headers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; indicates which domain is able to call this endpoint. The value should either be &lt;code&gt;*&lt;/code&gt; (means any domain) or the HTTP request header Origin. The HTTP request Origin is the domain where &lt;code&gt;fetch(..)&lt;/code&gt; is invoked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt; indicates which HTTP method is allowed. We can use &lt;code&gt;POST&lt;/code&gt; here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt; indicates which HTTP header is allowed. We can use &lt;code&gt;*&lt;/code&gt; for simplicity.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Vary&lt;/code&gt; influences how the response should be cached by the browser. Since this is about allowing a domain to invoke an HTTP endpoint, we can use the value &lt;code&gt;Origin&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response body is ignored. Therefore, we can use an empty string.&lt;/p&gt;

&lt;p&gt;After the browser gets the response back and deems the current domain is allowed to invoke &lt;code&gt;fetch(..)&lt;/code&gt; on the waitlist backend’s domain, the browser now sends the actual fetch request.&lt;/p&gt;

&lt;p&gt;It’s important that the response of this request contains the same CORS headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement Google Sheets integration
&lt;/h2&gt;

&lt;p&gt;I won’t go into details on how to implement a Google Sheets integration. There are many resources and examples online.&lt;/p&gt;

&lt;p&gt;One thing that surprises me is that it’s not a simple API key. To write to a Google Sheet, you will need a service account and request an access token before you can write to a Google Sheet.&lt;/p&gt;

&lt;p&gt;You can see an example in Java here: &lt;a href="https://github.com/tanin47/wait/blob/main/src/main/java/tanin/wait/GoogleSheetService.java" rel="noopener noreferrer"&gt;https://github.com/tanin47/wait/blob/main/src/main/java/tanin/wait/GoogleSheetService.java&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The advantages
&lt;/h2&gt;

&lt;p&gt;The low cost is obviously one advantage. You can host one waitlist backend that serves many landing pages.&lt;/p&gt;

&lt;p&gt;The other non-obvious advantage is that you own the form and the JS code completely. Because you only call &lt;code&gt;fetch(..)&lt;/code&gt; as if it was the backend on the same domain. This makes it easier to style and customize the after actions.&lt;/p&gt;

&lt;p&gt;Compared to, for example, &lt;a href="https://docs.netlify.com/manage/forms/setup/" rel="noopener noreferrer"&gt;Netlify Form&lt;/a&gt;, the customization of the after actions is limited (&lt;a href="https://docs.netlify.com/manage/forms/setup/#success-messages" rel="noopener noreferrer"&gt;doc&lt;/a&gt;). Any other solution that uses iframe or hosted the form elsewhere will have a similar limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Currently, I have &amp;gt;10 landing pages hosted on Netlify for free. They invoke &lt;code&gt;fetch(..)&lt;/code&gt; to my waitlist backend, which is hosted on &lt;a href="https://www.ovhcloud.com/en/" rel="noopener noreferrer"&gt;OVHcloud&lt;/a&gt; using &lt;a href="https://dokploy.com/" rel="noopener noreferrer"&gt;Dokploy&lt;/a&gt; for ~$4/month.&lt;/p&gt;

&lt;p&gt;It’s probably the cheapest way to implement a waitlist for many landing pages.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The basics of managing database schema changes</title>
      <dc:creator>Tanin Na Nakorn</dc:creator>
      <pubDate>Sun, 04 Jan 2026 12:51:09 +0000</pubDate>
      <link>https://dev.to/tanin47/managing-the-database-schema-changes-for-your-app-14dn</link>
      <guid>https://dev.to/tanin47/managing-the-database-schema-changes-for-your-app-14dn</guid>
      <description>&lt;p&gt;Building an application requires data storage and, consequently, database schemas. When you update a released application, you must often modify these schemas. Managing these changes safely and efficiently is a fundamental engineering challenge.&lt;/p&gt;

&lt;p&gt;This article outlines my approach to schema management, its trade-offs, and strategies for scaling to higher reliability. As a solopreneur, I prioritize simplicity and productivity. While I use Java and &lt;a href="https://github.com/tanin47/jmigrate" rel="noopener noreferrer"&gt;JMigrate&lt;/a&gt;, these concepts apply to any library like &lt;a href="https://github.com/flyway/flyway" rel="noopener noreferrer"&gt;Flyway&lt;/a&gt;, &lt;a href="https://github.com/liquibase/liquibase" rel="noopener noreferrer"&gt;Liquibase&lt;/a&gt;, &lt;a href="https://github.com/mybatis/migrations" rel="noopener noreferrer"&gt;MyBatis&lt;/a&gt; and across languages and frameworks like Rails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem of changing database schemas
&lt;/h2&gt;

&lt;p&gt;Suppose you create a user table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE TABLE "jmigrate_test_user" (
    id INTEGER PRIMARY KEY,
    username TEXT NOT NULL,
    hashed_password TEXT NOT NULL,
    password_expired_at TIMESTAMP
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need to add a &lt;code&gt;last_login&lt;/code&gt; column a week later, you could execute the SQL manually—a common practice in 1999. However, manual updates create two critical problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No Version Control:&lt;/strong&gt; Changes are untracked. You cannot identify when the source code began supporting the new column, nor can you easily revert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team Desynchronization:&lt;/strong&gt; You must coordinate manual updates across every teammate's local environment, which is error-prone and inefficient.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a colleague simultaneously attempts to add an age column, the lack of automation results in a "schema collision" that is difficult to resolve.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I solve it
&lt;/h3&gt;

&lt;p&gt;Every database change should be committed to Git. Store schema changes in a dedicated folder using sequential filenames, such as &lt;code&gt;1.sql&lt;/code&gt;, &lt;code&gt;2.sql&lt;/code&gt;, and &lt;code&gt;3.sql&lt;/code&gt;. To modify the schema, simply add the next file in the sequence (e.g., &lt;code&gt;4.sql&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Each migration script contains two sections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Up:&lt;/strong&gt; SQL code to advance the database schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Down:&lt;/strong&gt; SQL code to revert the changes made by the "Up" section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While production environments should prohibit "Down" scripts to prevent data loss, executing them in development is essential for agility. Tools like &lt;a href="https://github.com/tanin47/jmigrate" rel="noopener noreferrer"&gt;JMigrate&lt;/a&gt;, &lt;a href="https://github.com/flyway/flyway" rel="noopener noreferrer"&gt;Flyway&lt;/a&gt;, &lt;a href="https://github.com/liquibase/liquibase" rel="noopener noreferrer"&gt;Liquibase&lt;/a&gt;, and &lt;a href="https://github.com/mybatis/migrations" rel="noopener noreferrer"&gt;MyBatis&lt;/a&gt; automate this process. &lt;/p&gt;

&lt;p&gt;With &lt;a href="https://github.com/tanin47/jmigrate" rel="noopener noreferrer"&gt;JMigrate&lt;/a&gt;, a single call to &lt;code&gt;JMigrate.migrate()&lt;/code&gt; at application startup handles all pending migrations.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it works in practice
&lt;/h3&gt;

&lt;p&gt;Let's walkthrough 3 real-world scenarios:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 1: You want to make a database schema change and need to iterate on the migration script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose you add a &lt;code&gt;last_login&lt;/code&gt; column using bigint (for epoch milliseconds). You create &lt;code&gt;5.sql&lt;/code&gt; with the following content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# --- !Ups
ALTER TABLE "user" ADD COLUMN "last_login" BIGINT;

# --- !Downs
ALTER TABLE "user" DROP COLUMN "last_login";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running the script, you realize a &lt;code&gt;timestamp&lt;/code&gt; type is more appropriate. You then modify &lt;code&gt;5.sql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# --- !Ups
ALTER TABLE "user" ADD COLUMN "last_login" TIMESTAMP;

# --- !Downs
ALTER TABLE "user" DROP COLUMN "last_login";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development, JMigrate detects the modification. It automatically executes the Down script of the previous version (&lt;code&gt;DROP COLUMN&lt;/code&gt;) and the Up script of the revised version (&lt;code&gt;ADD COLUMN ... TIMESTAMP&lt;/code&gt;), ensuring your local database remains synchronized with your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 2: You are adding a migration script, and your co-worker is also adding one at the same time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You are adding a new migration script &lt;code&gt;5.sql&lt;/code&gt; because the last previously run migration script is &lt;code&gt;4.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your co-worker is adding &lt;code&gt;5.sql&lt;/code&gt; at the same time and has merged his change before you do.&lt;/p&gt;

&lt;p&gt;What happens is that there will be a git conflict, and you will have to resolve that before merging by moving your migration script to &lt;code&gt;6.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In your local development environment, JMigrate will run &lt;code&gt;5.sql&lt;/code&gt; (from your co-worker) and &lt;code&gt;6.sql&lt;/code&gt; (from you) automatically. This automation ensures your local environment remains synchronized, allowing you to continue working without manual database intervention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario 3: You accidentally modify a past migration script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You accidentally modified &lt;code&gt;3.sql&lt;/code&gt;, which had been previously deployed. &lt;/p&gt;

&lt;p&gt;Since forbidding executing the down scripts is set to true, JMigrate will throw an exception and fail the deployment. This is the best possible outcome because you wouldn't want to deploy a mistake.&lt;/p&gt;

&lt;p&gt;You will get an alert and are able to revert &lt;code&gt;3.sql&lt;/code&gt; and make a proper fix before attempting another deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make it more reliable
&lt;/h3&gt;

&lt;p&gt;At scale, the automated migration process reveals a flaw: non-backward-compatible changes cause downtime.&lt;/p&gt;

&lt;p&gt;Renaming a column illustrates this risk. If you rename &lt;code&gt;name&lt;/code&gt; to &lt;code&gt;full_name&lt;/code&gt;, the existing application instances will continue to query the old column &lt;code&gt;name&lt;/code&gt; until they are replaced. During this deployment window, the database will throw exceptions, crashing the application.&lt;/p&gt;

&lt;p&gt;To avoid this, engineers at companies like Stripe and Google often tolerate "bad" names rather than renaming columns. When a change is unavoidable, use a multi-step deployment to maintain availability:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add&lt;/strong&gt; the new column and deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual-write&lt;/strong&gt; to both the old and new columns and deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfill&lt;/strong&gt; data from the old column to the new one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read&lt;/strong&gt; from the new column only and deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove&lt;/strong&gt; the old column and deploy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note that for massive datasets, step three may take days and require specialized data-migration tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  JMigrate: a simple database schema migration for Java
&lt;/h3&gt;

&lt;p&gt;I developed JMigrate to provide a simpler, lightweight alternative.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;JMigrate&lt;/th&gt;
&lt;th&gt;Alternatives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simplicity&lt;/td&gt;
&lt;td&gt;A single function call handles all migrations.&lt;/td&gt;
&lt;td&gt;Often require complex configuration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration&lt;/td&gt;
&lt;td&gt;Pure Java; runs within the application.&lt;/td&gt;
&lt;td&gt;Frequently require a separate CLI, which platforms like Heroku and Render.com may restrict.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;14 KB&lt;/td&gt;
&lt;td&gt;800 KB (Flyway) to 3 MB (Liquibase).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;JMigrate is ideal for desktop and self-hosted applications where minimal file size and architectural simplicity are paramount. Conversely, large-scale server-side applications—where teams manage their own deployments—typically prioritize extensive feature sets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Managing database schema migrations is a fundamental engineering responsibility. While modern libraries automate best practices, engineers must understand the underlying mechanics to resolve exceptional cases, such as migration failures.&lt;/p&gt;

&lt;p&gt;Current standards facilitate simultaneous development, rigorous testing, and seamless deployment. Whether you choose either &lt;a href="https://github.com/tanin47/jmigrate" rel="noopener noreferrer"&gt;JMigrate&lt;/a&gt;, &lt;a href="https://github.com/flyway/flyway" rel="noopener noreferrer"&gt;Flyway&lt;/a&gt;, &lt;a href="https://github.com/liquibase/liquibase" rel="noopener noreferrer"&gt;Liquibase&lt;/a&gt;, or &lt;a href="https://github.com/mybatis/migrations" rel="noopener noreferrer"&gt;MyBatis&lt;/a&gt;, you are now equipped to manage schema changes with confidence.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>architecture</category>
      <category>java</category>
    </item>
  </channel>
</rss>
