Deploying a SolidStart app to Vercel with CircleCI
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:
- GitHub account
- CircleCI account
- Vercel account
- Supabase account
- pnpm installed on your local machine
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.

Select Supabase from the list of integrations and click Continue.

Select the Free Plan and click Continue.

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

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.

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

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
);

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.

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

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

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.

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.

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 Supabasescorestable.
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 (Indexcomponent)/playerloads the form to add a new player (Playercomponent)
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.

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.

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>.

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

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.

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

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.