<?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: Gabriel Abreu</title>
    <description>The latest articles on DEV Community by Gabriel Abreu (@gabbs279).</description>
    <link>https://dev.to/gabbs279</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%2F4088779%2F3f19a571-6ba6-4964-a022-bc33296091c2.jpg</url>
      <title>DEV Community: Gabriel Abreu</title>
      <link>https://dev.to/gabbs279</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabbs279"/>
    <language>en</language>
    <item>
      <title>Migrating an Express Backend to Vercel Functions Without Downtime</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Fri, 21 Aug 2026 19:59:20 +0000</pubDate>
      <link>https://dev.to/gabbs279/migrating-an-express-backend-to-vercel-functions-without-downtime-3kal</link>
      <guid>https://dev.to/gabbs279/migrating-an-express-backend-to-vercel-functions-without-downtime-3kal</guid>
      <description>&lt;p&gt;Earlier this year I migrated the analytics backend behind &lt;a href="https://codewithgabo.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=vercel-migration" rel="noopener noreferrer"&gt;this site&lt;/a&gt; from a long-running Express server on Railway to a set of Vercel Functions. The frontend never noticed. Cost dropped from a small monthly Railway bill to &lt;strong&gt;$0/month&lt;/strong&gt; on Vercel's hobby tier. This post walks through how I did it, what broke, and when this kind of migration actually makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why migrate at all
&lt;/h2&gt;

&lt;p&gt;The backend was a tiny Express server that did two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wrap the Google Analytics 4 Data API behind an admin-only endpoint so the dashboard on this site could read GA4 metrics without exposing the service-account key.&lt;/li&gt;
&lt;li&gt;Validate an &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; to gate that endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That was it. A few hundred lines of Node, three dependencies, one route. Running it as a 24/7 container on Railway was fine, but it had three quiet costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Money.&lt;/strong&gt; Hobby tier on Railway isn't free anymore. Even a small workload was a recurring charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold starts I didn't need.&lt;/strong&gt; The dashboard isn't visited often. The Express server sat idle 99% of the time, costing money to do nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy ceremony.&lt;/strong&gt; Two repositories, two pipelines, two environments to keep aligned. Every backend change meant context-switching to the Railway dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vercel Functions felt like the right shape: stateless handlers that spin up on request, scale to zero when idle, deploy from the same &lt;code&gt;git push&lt;/code&gt; flow as anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The before / after
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:
  Frontend
        │ HTTPS
        ▼
  Railway: long-running Node + Express
        ├─ /api/analytics
        ├─ middleware: cors, helmet
        └─ env: ADMIN_TOKEN, GA4 credentials

AFTER:
  Frontend
        │ HTTPS
        ▼
  Vercel Functions
        ├─ api/analytics.js (one handler per route)
        ├─ api/health.js
        └─ api/_utils/ (extracted helpers — auth, ga4 client)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same external contract, completely different runtime model. The frontend was not part of this migration — it was still on GitHub Pages at the time, and has since moved to Vercel too, but that was a separate change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Turn each Express route into a Vercel handler
&lt;/h2&gt;

&lt;p&gt;A Vercel Function is just a Node module that exports a handler matching &lt;code&gt;(req, res) =&amp;gt; void | Promise&amp;lt;void&amp;gt;&lt;/code&gt;. The shape is essentially &lt;code&gt;http.IncomingMessage&lt;/code&gt; / &lt;code&gt;http.ServerResponse&lt;/code&gt; with some extras.&lt;/p&gt;

&lt;p&gt;The Express version looked like this:&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;// Before&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/analytics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAnalyticsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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;The Vercel version is the same logic, no &lt;code&gt;app&lt;/code&gt;, no &lt;code&gt;next&lt;/code&gt;:&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;// After: api/analytics.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;applyCors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./_utils/auth&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;runAnalyticsQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./_utils/ga4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;applyCors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Method not allowed&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAnalyticsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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;Three things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No middleware chain.&lt;/strong&gt; Each handler runs the few checks it needs explicitly. With one or two routes per file, this is clearer than a global middleware stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS becomes a function call.&lt;/strong&gt; I extracted &lt;code&gt;applyCors&lt;/code&gt; to share between handlers. It returns &lt;code&gt;false&lt;/code&gt; and writes the response if the request was a preflight, so the handler can early-exit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth, same.&lt;/strong&gt; &lt;code&gt;requireAuth&lt;/code&gt; reads the &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header, compares to &lt;code&gt;process.env.ADMIN_TOKEN&lt;/code&gt;, and writes a 401 if it fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2 — Extract helpers into &lt;code&gt;api/_utils/&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Vercel ships every file in &lt;code&gt;api/&lt;/code&gt; (excluding &lt;code&gt;_&lt;/code&gt;-prefixed paths) as a public route. Anything starting with &lt;code&gt;_&lt;/code&gt; is treated as a private utility. This is the convention I used for shared code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api/
├── analytics.js
├── health.js
└── _utils/
    ├── auth.js
    └── ga4.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GA4 helper memoises the client across invocations, which matters because Vercel Functions reuse warm container instances when traffic is steady:&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;// api/_utils/ga4.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BetaAnalyticsDataClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@google-analytics/data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BetaAnalyticsDataClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;client_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GA4_CLIENT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GA4_PRIVATE_KEY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;n/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&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;That &lt;code&gt;replace(/\\n/g, '\n')&lt;/code&gt; is the one consistent pain point of moving service-account keys between secret stores. Vercel preserves real newlines in multi-line env values, so if you paste with literal &lt;code&gt;\n&lt;/code&gt; from a copied JSON file, you have to undo the escaping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Configure &lt;code&gt;vercel.json&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Vercel infers most of the project layout, but it's worth being explicit about CORS origins and Node version:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"functions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"api/**/*.js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"runtime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodejs20.x"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/(.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Access-Control-Allow-Origin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://codewithgabo.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep the dynamic origin allowlist (which supports multiple domains during development) inside &lt;code&gt;applyCors&lt;/code&gt; and use the static header here only as a defense-in-depth layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Migrate environment variables
&lt;/h2&gt;

&lt;p&gt;I copied each env var from Railway's dashboard to Vercel's, set them for &lt;strong&gt;Production + Preview + Development&lt;/strong&gt; so dev runs (&lt;code&gt;vercel dev&lt;/code&gt;) work locally. The two non-trivial ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GA4_PRIVATE_KEY&lt;/code&gt; — multi-line value with real newlines. Pasted directly; no escaping.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FRONTEND_URL&lt;/code&gt; — a comma-separated list (&lt;code&gt;https://codewithgabo.com,https://gabbs27.github.io,http://localhost:3000&lt;/code&gt;) consumed by the CORS helper. This was new — Express had it baked into a &lt;code&gt;cors()&lt;/code&gt; middleware config.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5 — Smoke-test before flipping traffic
&lt;/h2&gt;

&lt;p&gt;The old Railway service was still live during the migration. I deployed Vercel to a preview URL, hit it from &lt;code&gt;curl&lt;/code&gt; with a real &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;, confirmed the JSON response matched the Railway version, then updated the frontend's &lt;code&gt;VITE_ANALYTICS_API_URL&lt;/code&gt; env to point at the new Vercel alias and redeployed.&lt;/p&gt;

&lt;p&gt;Once the dashboard rendered metrics from Vercel, I stopped the Railway service. No DNS games, no routing rules, no reverse proxy cutover. The frontend rebuild was the cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three gotchas I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. File uploads.&lt;/strong&gt; A later endpoint (&lt;code&gt;api/upload.js&lt;/code&gt;) accepts a multipart image and proxies it to Sanity's asset API. Express handled multipart through &lt;code&gt;multer&lt;/code&gt; middleware. On Vercel I had to parse the multipart body manually because the platform's default body parser only handles JSON. Vercel exposes &lt;code&gt;req.body&lt;/code&gt; as a &lt;code&gt;Buffer&lt;/code&gt; for unknown content types — from there it's &lt;code&gt;busboy&lt;/code&gt; or roll-your-own boundary parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cold starts on the first hit of the day.&lt;/strong&gt; A Vercel Function that hasn't been invoked recently can take ~500–1500ms to spin up. For a dashboard that's only checked occasionally, this is fine. For a user-facing API on the critical path, it would be a real concern — pre-warming or pinning the function to a region helps, but it's a tradeoff to keep in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The&lt;/strong&gt; &lt;code&gt;_utils/&lt;/code&gt; &lt;strong&gt;import path.&lt;/strong&gt; The first time I deployed, I forgot that Vercel's build doesn't follow files outside &lt;code&gt;api/&lt;/code&gt; automatically. Putting helpers under &lt;code&gt;api/_utils/&lt;/code&gt; (with the leading underscore) lets the bundler find them while keeping them private.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Concrete before / after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monthly cost:&lt;/strong&gt; small but recurring → &lt;strong&gt;$0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start:&lt;/strong&gt; ~50 ms (always warm on Railway) → 500–1500 ms on first hit, then cached&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy flow:&lt;/strong&gt; separate Railway dashboard → &lt;code&gt;git push&lt;/code&gt; from the same monorepo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependencies:&lt;/strong&gt; &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;cors&lt;/code&gt;, &lt;code&gt;helmet&lt;/code&gt;, &lt;code&gt;nodemon&lt;/code&gt; → &lt;strong&gt;none&lt;/strong&gt; (the platform handles all of it)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo footprint:&lt;/strong&gt; &lt;code&gt;server.js&lt;/code&gt;, &lt;code&gt;routes/&lt;/code&gt;, &lt;code&gt;middleware/&lt;/code&gt; → a handful of small handlers under &lt;code&gt;api/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small admin endpoint like this one, Vercel Functions are clearly the right fit. For a high-traffic API where every millisecond matters, Railway or Fly would still be the better answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to do this
&lt;/h2&gt;

&lt;p&gt;A migration like this only makes sense if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your traffic is bursty or low — cold starts won't dominate the user experience.&lt;/li&gt;
&lt;li&gt;Your handlers are stateless. Long-lived WebSocket connections, in-memory caches, or background workers don't translate cleanly.&lt;/li&gt;
&lt;li&gt;You can live with your plan's per-invocation timeout. Check the current limit for your tier before you assume a long-running job will fit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running a chat server, a worker pool, or anything with sticky in-memory state, stay on a long-running container. For everything else — REST endpoints, webhooks, glue between services — serverless is hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;The most underrated part of this migration was deleting &lt;code&gt;server.js&lt;/code&gt;, the &lt;code&gt;routes/&lt;/code&gt; folder, and four packages from &lt;code&gt;package.json&lt;/code&gt;. Less code is less to maintain. Less infrastructure is less to monitor. The new setup does exactly the same thing, on better terms, with fewer moving parts.&lt;/p&gt;

&lt;p&gt;If you have a small backend doing low-traffic admin work and you're paying month after month to keep it running, this is worth half a day of your time.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=vercel-migration" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vercel</category>
      <category>serverless</category>
      <category>node</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
