DEV Community

Cover image for How To Build Full Stack App With Vercel: Express, Supabase and ejs
Sk
Sk

Posted on

How To Build Full Stack App With Vercel: Express, Supabase and ejs

Serverless doesn’t mean there’s no server - it means you don’t manage the server or the infrastructure.

It’s a runtime (e.g., Node.js) spun up and torn down on demand. On Vercel’s hobby plan you get ~10s for a request/response cycle, up to 300s on Pro, and 800s on Fluid.

Moral of the story: if you’re doing something serverless, do it fast. Or break it up into smaller functions (FaaS: Function as a Service), which is exactly how, many modern APIs work.

One handy mantra: You pay for execution, not uptime.
Traditional hosting: you pay while the server is online.

Treat endpoints like quick, efficient functions and you’ll be fine, especially on Vercel (we’ve heard horror stories of surprise bills).

In this post, we’ll build a full-stack app (Node.js, Express, Supabase, and EJS) using Vercel, the most popular serverless platform.


Getting Started

  1. Make a Vercel account
    free tier works fine.

  2. Install the Vercel CLI:

   npm i -g vercel
Enter fullscreen mode Exit fullscreen mode
  1. Log in:
   vercel login
Enter fullscreen mode Exit fullscreen mode

Create a New Project Folder

npm init -y
npm i express @supabase/supabase-js ejs
npm i -D @types/node @vercel/node
Enter fullscreen mode Exit fullscreen mode

Create an api folder, each file here is an endpoint (index is / by default):

api/index.ts
Enter fullscreen mode Exit fullscreen mode

Stub out a minimal Express app:

const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.status(200).send("hello");
});

app.listen(3000, () => console.log("Server ready on port 3000."));

// Important: export for Vercel
module.exports = app;
Enter fullscreen mode Exit fullscreen mode

Add vercel.json

Think of this as Vercel’s package.json, it tells Vercel how to handle your project:

{
  "version": 2,
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/api"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Run the Dev Server

vercel dev
Enter fullscreen mode Exit fullscreen mode

Follow the prompts:

? Link to existing project? no
? What’s your project’s name? basic
? In which directory is your code located? ./
Enter fullscreen mode Exit fullscreen mode

If it worked, you’ll see:

Linked to [your-username]-projects/basic (created .vercel and added it to .gitignore)
Ready! Available at http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

That’s the full flow for spinning up a Vercel project.


Connecting Supabase

Mantra #2: Serverless pairs beautifully with edge databases, and Supabase is one of them.

Remember, you’ve only got a limited window between request and response. An edge database keeps latency low and plays perfectly in this setup.

Head over to your Vercel dashboard, pick the project you just created, and find the Storage tab. Select Supabase and follow the prompts, it’s a quick setup.

storage tab

Once it’s ready:

  1. Connect, Open in Supabase → Table EditorNew Table -> links.
  2. Disable Row Level Security (RLS) for now, otherwise it’ll require auth (whole other rabbit hole for another post).

Table

Vercel will automatically handle and store your environment variables. To use them locally, pull them down with:

vercel env pull .env.development.local
Enter fullscreen mode Exit fullscreen mode

You should now see your Supabase keys and related env vars in that file.


Deploying

When you’re ready to ship your first version:

vercel
Enter fullscreen mode Exit fullscreen mode

And that’s it, you’ve got a working FaaS (Function-as-a-Service) app live on Vercel.

Next, let’s level up and build a Linktree clone, the “Hello World” of full-stack apps.


Building a Temu Linktree

Now that Supabase is hooked up and your backend is talking to a database, you are ready to put it to use by building something a bit more fun, a Linktree clone.

The “Hello World” of full-stack apps: a simple UI, a few forms, and some database interactions. Perfect for practicing the serverless + edge DB pattern we just set up.


Quick note on EJS:

EJS is our templating engine for this project. It’s… verbose. HTML is verbose. For example, my add-link.ejs file is over 600 lines (yes, v0 can get a bit excessive).

To save time, just grab the views folder from the repo. The templates aren’t the star of the show, your API is.

At this point you should have a project structure like this:

api
views
Enter fullscreen mode Exit fullscreen mode

EJS works by taking special syntax in your .ejs files, running it through a parser, and outputting HTML with the values injected at runtime.

Example:

<div class="profile-avatar">
  <%= (user.username || 'U').charAt(0).toUpperCase() %>
</div>

<h1>@<%= user.username || 'user' %></h1>
<p><%= user.bio || 'Welcome to my link collection!' %></p>
Enter fullscreen mode Exit fullscreen mode

When rendered, those <%= ... %> bits will be replaced with actual values from your backend.

Frontend’s ready, let’s hook up the backend.


Backend Setup

Open index.ts and extend your Express app:

import path from "path";
import { createClient } from '@supabase/supabase-js';

const app = express();
app.use(express.urlencoded({ extended: true }));
Enter fullscreen mode Exit fullscreen mode

Create the Supabase client

const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;

if (!SUPABASE_URL) throw new Error('Missing env var SUPABASE_URL');
if (!SUPABASE_ANON_KEY) throw new Error('Missing env var SUPABASE_ANON_KEY');

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
Enter fullscreen mode Exit fullscreen mode

Register the views

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views'));
Enter fullscreen mode Exit fullscreen mode

Endpoints

Home page:

app.get("/", (req, res) => {
  res.render('add-link', {
    user: { username: "sk" },
    formData: {},   // prevents formData.title from blowing up
    errors: {},
    baseUrl: process.env.VERCEL_URL
  });
});
Enter fullscreen mode Exit fullscreen mode

That object is passed into add-link.ejs so it can dynamically render HTML.


Add link:

app.post("/links/new", async (req, res) => {
  const data = req.body;    
  const errors = validate(data);

  if (Object.keys(errors).length) {
    return res.render('add-link', {
      user: req.user,
      formData: data,
      errors
    });
  }

  try {
    await saveLink(data); // helper function below
    return res.status(302).setHeader('Location', '/dashboard').end();
  } catch (err) {
    console.error('saveLink failed:', err);
    return res.status(500).json({
      success: false,
      message: err instanceof Error ? err.message : 'Unknown error'
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Save link & validation:

async function saveLink(link) {
  const { data, error } = await supabase
    .from('links')
    .insert([{
      title: link.title,
      url: link.url,
      shortCode: link.shortCode,
      category: link.category,
      description: link.description
    }])
    .select();

  if (error) {
    console.error('Supabase insert error:', error);
    throw new Error(error.message);
  }
  return data;
}

function validate(data) {
  const errs = {};
  if (!data.title) errs.title = 'Title is required';
  // ...etc...
  return errs;
}
Enter fullscreen mode Exit fullscreen mode

Dashboard:

app.get("/dashboard", async (req, res) => {
  let { data: links, error } = await supabase
    .from('links')
    .select('*');

  if (error) {
    return res.status(500).json({
      success: false,
      message: error instanceof Error ? error.message : 'Unknown error'
    });
  }

  res.render('display', {
    user: { username: "sk" },
    totalClicks: 10,
    links
  });
});
Enter fullscreen mode Exit fullscreen mode

Navigate to a link:

app.get('/navigate/:nav', (req, res) => {
  const raw = req.params.nav;

  // Always validate & sanitize here — skipping this = trouble
  let url;
  try {
    url = new URL(raw);
  } catch {
    return res.status(400).send('Invalid URL');
  }

  if (!['http:', 'https:'].includes(url.protocol)) {
    return res.status(400).send('Only HTTP/S allowed');
  }

  return res.redirect(url.toString());
});
Enter fullscreen mode Exit fullscreen mode

And there you have it, the most basic full-stack “Hello World.” Nearly any production-grade app will follow this same pattern:

  • render a page
  • accept data from a form
  • store it
  • show it back to the user

Test it locally, and when it’s ready:

vercel
Enter fullscreen mode Exit fullscreen mode

You’ve now gone from serverless fundamentalsedge database integrationworking full-stack app in one smooth flow.

Want to add Auth? How To Setup Auth With Vercel Serverless and Supabase.

Ever wondered what it really takes to build low-level Node.js tooling or distributed systems from scratch?

  • Learn raw TCP
  • Go over a message broker in pure JavaScript
  • Go from "can code" to "can engineer"

Check out: How to Go from a 6.5 to an 8.5 Developer

Or maybe you're ready to master the dark art of authentication?

  • From salting and peppering to mock auth libraries
  • Understand tokens, sessions, and identity probes
  • Confidently use (or ditch) auth-as-a-service or roll out your own?

Grab: The Authentication Handbook: With Node.js Examples

thanks for reading. 🙏

Top comments (3)

Collapse
 
sfundomhlungu profile image
Sk • Edited

For "intense work" checkout the fluid compute offer:

vercel Fluid Compute

Add Supabase Auth:

Collapse
 
jessica_karen_af16ea6a72d profile image
Jessica Karen

Really clear walkthrough — I like how you tie the serverless mindset (“do it fast”) to practical implementation on Vercel. The Supabase integration and Linktree example make the concepts stick, and the tips on environment variables and validation are a nice touch for anyone going beyond a demo.

Collapse
 
batchdata profile image
BatchData

interesting information, thank you