TutorialsNov 6, 202510 min read

Deploying a SolidStart app to Vercel with CircleCI

Marcelo Oliveira

Senior Software Engineer

Deploying web apps can feel overwhelming. Multiple moving parts, including frameworks, hosting, databases, and automation tools make having a smooth, automated workflow seem impossible. But having an automated workflow is worth the effort; you can focus on building features and improving your app instead of worrying about manual deployments or server management. Whether you’re launching a new project, experimenting with modern frameworks, or want to streamline your release process, a reliable CI/CD setup helps you move faster and avoid headaches.

In this tutorial, you will set up a complete CI/CD pipeline for a SolidStart app. You’ll use CircleCI to automate builds and Vercel for seamless hosting. You will connect your app to a Supabase database, making it easy to store and retrieve data. By the end, you will have a modern web app that automatically transitions from code to production, allowing you to ship updates with confidence and spend more time coding, not configuring.

Prerequisites

You’ll need to have some things in place before you get started:

Setting up Vercel and Supabase

To deploy your SolidStart app, you will use Vercel, a platform that simplifies the deployment of web applications. Vercel provides seamless integration with GitHub and supports various frameworks, including SolidStart.

  • Vercel simplifies the deployment of your SolidStart leaderboard app, requiring minimal setup. It supports server-side rendering (SSR), static site generation (SSG), and dynamic routing, allowing you to build and launch your project quickly—without the usual server-related complications.
  • Supabase is an open-source backend-as-a-service (BaaS) that provides a powerful and developer-friendly database for your leaderboard. It seamlessly integrates with Vercel and SolidStart, enabling you to store and retrieve player scores effortlessly. Additionally, Supabase offers a RESTful API and real-time features, making it an excellent choice for interactive applications like your leaderboard app.

Integrate the Vercel store with Supabase

Open the Vercel dashboard. Click Add New, then click Store.

Add new store

Select Supabase from the list of integrations and click Continue.

Vercel add Supabase

Select the Free Plan and click Continue.

Create database

Accept the generated database name (or change it to anything you like). Click Create.

Confirm database

When your database is created, click Done to open the database’s details page. This page is where you get your Supabase credentials. Click the Show Secret button. Copy the NEXT_PUBLIC_SUPABASE_ANON_KEY and NEXT_PUBLIC_SUPABASE_URL values. You will need these later in your SolidStart app to connect to Supabase.

Once you have the credentials, click the Open in Supabase button in the Vercel dashboard.

Supabase credentials

On the Supabase dashboard, open the SQL editor by clicking SQL Editor.

Supabase dashboard

In the editor, create the scores table by running:

CREATE TABLE scores (
  id integer generated always as identity primary key,
  avatar varchar NOT NULL,
  playername varchar NOT NULL,
  points integer NOT NULL
);

SQL editor

Still in Supabase, click Table Editor, then select the scores table you just created. Enable RLS (Row Level Security) to restrict access by default. Then you’ll need to define policies to allow your SolidStart app to read or write data as needed.

Enable RLS

Once RLS is enabled, click Add RLS policy. On the Policies page, click the Create policy button.

Create RLS policy

In the policy editor, set these options:

  • Policy Name: permissive
  • Table: public.scores
  • Policy Command for clause: All
  • Provide a SQL expression for the using statement: true
  • Provide a SQL expression for the check statement: true

Permissive policy

Click the Save policy button to allow all users, including anonymous ones, to read and write to the scores table. This is the most permissive policy, granting full access to anyone with your project’s anon key.

Creating a Vercel token

To deploy your SolidStart app to Vercel from CI, you need to create a Vercel token. CircleCI will use this token to authenticate with Vercel and deploy your app.

Provide a token name and define the scope as your-username projects. Set the expiration to 1 year and click Create Token.

Create Vercel token

Make a note of your token; you will need to use it later on in the tutorial.

Creating the SolidStart startup project

SolidStart is a modern web framework built on top of SolidJS, designed to simplify the development of server-rendered applications. It provides a powerful routing system, server-side rendering (SSR), and seamless integration with various data stores, making it an excellent choice for building dynamic web applications like a leaderboard.

To create the SolidStart project, open GitHub and create a repository named solid-leaderboard. Select Node from the Add .gitignore list.

Open your terminal and clone that repository to your local machine:

git clone git@:<your-user-name>/solid-leaderboard.git # Using SSH

git clone https:///<your-user-name>/solid-leaderboard.git # Using HTTPS

Now, cd into the project folder and create a SolidStart application using the pnpm package manager:

cd solid-leaderboard
pnpm create solid@latest .

This command will prompt you to choose some options. Here’s what to enter:

  • What type of project would you like to create?: SolidStart
  • Use Typescript?: Yes
  • Which template would you like to use?: with-trpc

When the project is created, you can install the dependencies. Start the development server to review your basic SolidStart app:

pnpm install
pnpm run dev

Open the app by entering http://localhost:3000 in your browser.

Solidstart Hello World

Building the game leaderboard project

Now you can build a simple game leaderboard application using SolidStart. This app will enable users to view and submit scores, demonstrating the capabilities of SolidStart for building reactive user interfaces.

Install the dependencies for your SolidStart project. In your terminal, run this command:

pnpm install @supabase/supabase-js @trpc/server

This command installs the Supabase client library for interacting with your Supabase backend and the tRPC server library for building type-safe APIs.

Remove the src/components folder; it’s not needed for this new project.

rm -rf src/components

If you are using Windows, use this command instead:

rd "src\components" -r

Create the interface

Open the src/routes/index.tsx file and replace its content with code that creates a basic leaderboard interface. Enter:

import { createSignal, onMount, For } from "solid-js";
import { useNavigate } from "@solidjs/router";

let scoresApiUrl = "/api/scores";
if (process.env.VERCEL_URL) {
  scoresApiUrl = `http://${process.env.VERCEL_URL}${scoresApiUrl}`;
}

const getScores = async () => {
  const res = await fetch(scoresApiUrl);
  return res.json();
};

const deleteScore = async (id: number) => {
  const res = await fetch(`${scoresApiUrl}/${id}`, {
    method: "DELETE",
  });
  return res.ok;
};

export default function Leaderboard() {
  const [scores, setScores] = createSignal([]);

  onMount(async () => {
    const data = await getScores();
    setScores(data);
  });

  const navigate = useNavigate();

  async function onDelete(id: number) {
    await deleteScore(id);
    const newScores = scores().filter((s: any) => s.id !== id);
    setScores(newScores);
    await deleteScore(id);
  }

  return (
    <div class="container mt-4">
      <div class="alert alert-success text-center h2">GAME LEADERBOARD</div>
      <div class="bg-dark text-white row py-2">
        <div class="col-1 text-center">#</div>
        <div class="col-5">Player</div>
        <div class="col-4 text-end">Points</div>
        <div class="col-2">Delete</div>
      </div>
      {scores()?.map((s: any) => (
        <div class="row py-2 border-bottom">
          <div class="col-1 text-center">{s.ranking}</div>
          <div class="col-5">{s.avatar} {s.playername}</div>
          <div class="col-4 text-end">{s.points}</div>
          <div class="col-2 text-end">
            <button class="btn btn-danger" onClick={async () => onDelete(s.id)}>❌</button>
          </div>
        </div>
      ))}
      <div class="text-end mt-3">
        <button class="btn btn-primary" onClick={() => navigate("/player")}>
          ➕ Add New Entry
        </button>
      </div>
    </div>
  );
}

The index.tsx component shows the list of players and their scores, provides delete functionality and a navigation button:

onMount(async () => {
  const data = await getScores();
  setScores(data);
});

When the component is mounted, it fetches the scores from the /api/scores endpoint and displays them in a Bootstrap-styled table.

When users click "➕" to add a new entry, SolidStart routes to /player.

When users click the ❌ button next to a score, it calls the onDelete function. That sends a DELETE request to the /api/scores/[id] endpoint to remove that score from the leaderboard.

Add player score form

Create a new src/routes/player.tsx file to handle player score submissions. This file will contain a form for users to submit their scores and display the leaderboard. In the newly created file, paste this content:

import { createSignal } from "solid-js";
import { useNavigate } from "@solidjs/router";

export default function PlayerForm() {
  const navigate = useNavigate();
  const [playername, setPlayername] = createSignal("");
  const [points, setPoints] = createSignal(0);
  const [avatar, setAvatar] = createSignal("0");

  const avatars = {
    "0": "not set",
    "1": "👨🏻",
    "2": "👨🏼",
    "3": "👨🏽",
    "4": "👨🏾",
    "5": "👨🏿",
    "6": "👩🏻",
    "7": "👩🏼",
    "8": "👩🏽",
    "9": "👩🏾",
    "10": "👩🏿",
  };

  const handleSubmit = async (e: any) => {
    e.preventDefault();

    let scoresApiUrl = "/api/scores";

    if (process.env.VERCEL_URL) {
      scoresApiUrl = `http://${process.env.VERCEL_URL}${scoresApiUrl}`;
    }

    await fetch(scoresApiUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ avatar: avatar(), playername: playername(), points: points() }),
    }).then((res) => {
      if (!res.ok) {
        // Handle error
        alert("Failed to add entry: " + res.statusText);
        console.error("Failed to add entry:", res.statusText);
        return;
      }
      navigate("/");
    });
  };

  return (
    <form onSubmit={handleSubmit} class="container mt-4">
      <div class="alert alert-success text-center h2">Add New Player</div>
      <div class="row bg-dark text-white py-2">
        <div class="col-3">Avatar</div>
        <div class="col-6">Player Name</div>
        <div class="col-3 text-end">Points</div>
      </div>
      <div class="row py-2">
        <div class="col-3">
          <select class="form-control" value={avatar()} onInput={(e) => setAvatar(e.currentTarget.value)}>
            {Object.entries(avatars).map(([value, label]) => (
              <option value={value}>{label}</option>
            ))}
          </select>
        </div>
        <div class="col-6">
          <input class="form-control" type="text" value={playername()} onInput={(e) => setPlayername(e.currentTarget.value)} />
        </div>
        <div class="col-3">
          <input class="form-control" type="number" value={points()} onInput={(e) => setPoints(+e.currentTarget.value)} />
        </div>
      </div>
      <div class="text-end mt-3">
        <button class="btn btn-success me-2" type="submit">✔ Confirm</button>
        <button class="btn btn-secondary" type="button" onClick={() => navigate("/")}>✖ Cancel</button>
      </div>
    </form>
  );
}

In the previous code, the player.tsx component renders a form for submitting new leaderboard entries:

const handleSubmit = async (e: any) => {
  e.preventDefault();
  await fetch(scoresApiUrl, {
    method: "POST",
    ...
  });
};

Users then fill in:

  • An avatar (by selecting from a list)
  • Player name
  • Score (points)

When the form is submitted, it sends a POST request to the /api/scores API route. If successful, it returns to the leaderboard view.

Manage API requests for fetching and submitting scores

Create a new file named src/routes/api/scores/index.ts to handle the API requests for fetching and submitting scores. This file defines the API endpoints for retrieving all scores and adding a new score. In the newly created file, paste:

import { APIEvent } from "@solidjs/start/server";
import { readBody } from "vinxi/http";
import { createClient } from '@supabase/supabase-js';
import { json } from "@solidjs/router";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);

export async function GET({ params }: APIEvent) {

  const { data: scores, error } = await supabase
    .from('scores')
    .select('*');

  if (error) {
    return new Response(JSON.stringify({ error: error.message }), { status: 500 });
  }

  const avatarDic = getAvatarDic();

  const sorted = (scores ?? [])
    .sort((a, b) => b.points - a.points)
    .map((s, i) => ({
      id: s.id,
      ranking: i + 1,
      avatar: avatarDic[s.avatar.toString()] || "not set",
      playername: s.playername,
      points: s.points,
    }));

    return new Response(JSON.stringify(sorted), {
    headers: {
      "Content-Type": "application/json",
    },
  });
}

export async function POST({ params }: APIEvent) {
  const body = await readBody(params);
  const { avatar, playername, points } = body;

  const { data: insertResult, error: insertError } = await supabase
    .from('scores')
    .insert([{ avatar: parseInt(avatar), playername, points: parseInt(points) }])
    .select()
    .single();

  if (insertError) {
    return new Response(JSON.stringify({ error: insertError.message }), { status: 400 });
  }

  return json(insertResult)
}

function getAvatarDic(): Record<string, string> {
  return {
    "0": "not set",
    "1": "👨🏻",
    "2": "👨🏼",
    "3": "👨🏽",
    "4": "👨🏾",
    "5": "👨🏿",
    "6": "👩🏻",
    "7": "👩🏼",
    "8": "👩🏽",
    "9": "👩🏾",
    "10": "👩🏿",
  };
}

The index.ts file handles two HTTP methods:

  • GET: Retrieves all scores, sorts them by points in descending order, assigns ranking numbers, and returns a clean JSON list.
  • POST: Accepts a new score (avatar, player name, points) and inserts it into the Supabase scores table.

It also defines a helper function getAvatarDic() to convert avatar codes ("1") into emojis ("👨🏻").

Add delete scores functionality

Add the src/routes/api/scores/[id].ts file to handle deleting scores by ID. Enter this code:

import { APIEvent } from "@solidjs/start/server";
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);

export async function DELETE({ params }: APIEvent) {
  const id = params.id;
  const { error } = await supabase
    .from('scores')
    .delete()
    .eq('id', id);
  if (error) {
    return new Response(JSON.stringify({ error: error.message }), { status: 500 });
  }
  return new Response(JSON.stringify({ message: 'Deleted successfully' }), { status: 200 });
}

This code handles the deletion of a specific score using the dynamic route parameter id. If the deletion fails, it returns a 500 error. If successful, it returns a confirmation response. This route is triggered by the ❌ button in the leaderboard.

Modify the src/app.tsx file to include the new routes and styles. Replace its content with this code:

import type { Component } from 'solid-js';
import { Router, Route } from '@solidjs/router';
import Index from './routes/index';
import Player from './routes/player';

const App: Component = () => {
  return (
    <Router>
        <Route path="/" component={Index} />
        <Route path="/player" component={Player} />
    </Router>
  );
};

export default App;

Set up app navigation

The app.tsx file is the navigation blueprint of your application. It uses SolidJS’s router to define how users move between pages.

In this setup, two routes are configured:

  • / loads the leaderboard (Index component)
  • /player loads the form to add a new player (Player component)

This file enables seamless navigation between views while maintaining the modularity of your application.

Edit the src/entry-server.tsx file by adding lines to the <head> section. Include the title and Bootstrap CSS for styling:

<title>Game Leaderboard</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"/>

The server entry point defines the structure of the rendered HTML document. It’s where you configure SEO, stylesheets, and overall layout. This HTML shell is reused on every server-side page render and includes Bootstrap for styling, as well as meta tags and a favicon.

Create a .env file in the root of your SolidStart project. Add the environment variables you copied from Vercel earlier:

NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
NEXT_PUBLIC_SUPABASE_URL="your-supabase-url"

Run the pnpm run dev command to run the SolidStart app locally. When the development server is running, open your browser and go to http://localhost:3000 to review your SolidStart app. It should be running with the leaderboard functionality.

SolidStart leaderboard

Click Add new entry to add a new player to the leaderboard. Select an avatar, enter a player name, and add the points. After you submit, the new entry will appear in the leaderboard.

Add new entry

Automating deployments CircleCI

Now you can start automating the deployment of your SolidStart app to Vercel using CircleCI.

Your CircleCI configuration will define a deployment pipeline that checks out your code, installs dependencies, sets up the Vercel CLI, and deploys your app using environment variables for authentication. Each step runs in a clean Docker container to ensure consistent and reliable builds.

Create a .circleci/config.yml file in your project and add this:

version: 2.1

jobs:
  deploy:
    docker:
      - image: cimg/node:22.2
    steps:
      - checkout
      - run:
          name: Install dependencies
          command: pnpm install
      - run:
          name: Install Vercel CLI locally
          command: pnpm install vercel
      - run:
          name: Deploy to Vercel
          command: |
            pnpm vercel pull --yes --token=$VERCEL_TOKEN
            pnpm vercel link --project $VERCEL_PROJECT_NAME --token=$VERCEL_TOKEN --yes
            pnpm vercel deploy --yes --prod --token=$VERCEL_TOKEN \
              --env NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \
              --env NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL

workflows:
  deploy:
    jobs:
      - deploy

This setup will run every time a new commit is pushed. It deploys your app automatically to Vercel using the values from the environment variables.

After adding the config file, commit all the changes you have made so far and push them to your GitHub repository. Then, create a new project using your GitHub repo. Don’t trigger the pipeline yet.

Open your project’s settings on CircleCI and set these environment variables:

Environment variable Name Value
NEXT_PUBLIC_SUPABASE_ANON_KEY your supabase database anon key
NEXT_PUBLIC_SUPABASE_URL your supabase database url
VERCEL_PROJECT_NAME solid-leaderboard
VERCEL_TOKEN your vercel token

Note: Because Vercel project names are unique, provide a slightly different value for the VERCEL_PROJECT_NAME variable, such as solid-leaderboard-<your-github-username>.

CircleCI project environment variables

With the environment variables set up, you can now trigger your pipeline manually. It should execute successfully.

Successful workflow execution

Running the SolidStart app from Vercel

Your CircleCI workflow has successfully built and deployed your SolidStart app to Vercel. Open your Vercel project dashboard and click your project’s URL.

Vercel project dashboard

Your deployed SolidStart app will open, where you can interact with the leaderboard and add new player entries.

Vercel deployed SolidStart app

Conclusion

Congratulations! You’ve just deployed a full-stack SolidStart leaderboard app, powered by Supabase and hosted on Vercel—with every step automated by CircleCI. Any time you push code, your pipeline handles the build and deployment, ensuring your latest features are always live and your workflow stays seamless. This setup means you can focus on building great apps, knowing your releases are reliable and hands-off for your entire team.

You can check out the complete source code on GitHub used in this tutorial on GitHub. Feel free to use the repository as a starting point for your own SolidStart projects and deployments.


Marcelo Oliveira is a senior software engineer from São Paulo, Brazil who combines technical expertise with a passion for developing innovative solutions. As a dedicated technical writer, he contributes significantly to the developer community through educational content that simplifies complex concepts and empowers programmers of various skill levels to enhance their capabilities.