May 3, 2026 • 7 min read
During my time at Gardener’s, one of my primary goals was to improve the performance of the site. In the year and a half I was there, the team and I made great progress. We sped up page load, reduced layout shift, and optimized SEO.
Unfortunately, the site we worked on has since been taken down1. I still want to be able to show off what I learned working there, so I decided to build Start Faster. The purpose of this shop isn’t to make money, it’s to demonstrate how performant an e-commerce platform can be.
Start Faster is a suite of tools for building an online shop. It includes a custom storefront for your Shopify catalog, a CMS for creating site content, and an admin portal for managing user permissions.
The project is open source and MIT licensed, feel free to check it out on GitHub. If you have any questions, feel free to message me on Twitter.
Shopify is an excellent product. They have built an incredible set of tools that allow you to get your shop up and selling in no time. While problems like order management and payment processing are difficult to solve, and I don’t think it makes sense to re-invent the wheel, I do think there is still merit to building your own custom storefront. Having full control over your site allows you to deliver the exact user experience you want your customers to have.
There isn’t one thing that makes it great. It’s a healthy balance of good stack decisions and thoughtful architecture. Let’s dive deep into a few of the things that shine the brightest.
If you want to:
Then the URL might be the perfect place for you to store state.
All of these cases show up when building an online shop. If a customer bookmarks a shirt in a specific size and color, the bookmark should open the page to that exact variant. If they’re on a search page and refresh, their filters should persist.
/search?q=alien&sortBy=price
The issue is that most meta-frameworks don’t provide a way to access and set URL
params in a type-safe manner. In Next.js, accesses are always typed
string | undefined, and you can set them to be whatever string you’d like.
When it comes to validating their types and values, the ball is in your court.
One of the reasons TanStack Router (the core of TanStack Start) was built was to provide a way to treat the URL as a predictable state management solution. In Start, you define your URL params at the Route level2. When you access them, they will be typed as the return type of their validator. When you set them, you will get a type error if the value you provide does not match the validator return type. If the user’s URL param values don’t pass validation, an error will be thrown and the route component will not be rendered.
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
export const Route = createFileRoute("/dashboard")({
component: DashboardRoute,
validateSearch: z.object({
q: z.string(),
tab: z.enum(["General", "Sales", "Marketing"]).default("General"),
}),
});
function DashboardRoute() {
// typed as string
const queryParam = Route.useSearch({ select: (search) => search.q });
// typed as "General" | "Sales" | "Marketing"
const currentTab = Route.useSearch({ select: (search) => search.tab });
}
When you access one of these parameter values, you know for certain% what the shape of the data will be, no surprises. If the value of the param changes, the component will re-render in true React fashion.
You should always use the select option when using the useSearch
hook. This way, the state variable will only change and cause a re-render when
the selector expression’s output changes, not when an unrelated URL param
changes.
If you check out this page on Start Faster, you can see these principles in action. When you open the page, the search bar and filters will be exactly as they were when I copied the link to put it in this post.
When you navigate between pages on Start Faster, you’ll notice that most of the time the destination page loads instantly.
This is due to:
Route caching comes out of the box with TanStack Start. When you define a route, you can specify a loader function that will load data to be consumed in the route component. This data is stored in the router cache, so if you navigate away and back to a page, it won’t be refetched unless its stale. You can configure the cache timings to your hearts content, but the defaults are quite good.
The quick link component does not come with Start. It’s a custom component3
that replaces TanStack Start’s <Link>. It makes it so that navigation happens
on mouse down instead of mouse up, which provides faster perceived4 INP. It
also sets a dynamic default for preloading behavior. On screens with a cursor,
links are preloaded when they’re hovered. On touch screens, they’re preloaded
when they enter the viewport. This ensures that navigations happen as quickly as
possible, no matter what platform the user is browsing on.
E-commerce sites demand large high quality images and it’s important to load them with speed and grace.
Shopify puts a CDN in front of their images, but I found that the load times felt faster when putting Vercel’s Image Optimization between Shopify and the user.
Getting images to load gracefully was tricky. Most browsers have decent image loading behavior, but some (firefox) do not. The solution I arrived at was to avoid the default browser behavior at all costs. The philosophy is as follows:
I found this to be the best possible configuration. Navigating back and forth between pages avoids replaying the fade in animation, while first page loads fade in smoothly with no flicker.
Whenever the user performs an action, I try to quickly make sure that there is an indication that the action they attempted to take was received. Ideally, I even show the anticipated response, even if the server hasn’t sent it yet. In other words, an Optimistic update.
The cart benefits immensely from this. When you click “Add to Cart” on a product page, the product immediately appears in your cart list, even though the server hasn’t sent back the new cart data yet. You can spam the buttons to increase or decrease quantity as fast as you’d like, and the final amount shown will always be right, and will always show immediately.
The key to doing this was to not show the cart data last sent from the server, but to show what it would look like if all of the outgoing mutations were applied to it. Since a mutation is no longer pending as soon as the new response data comes back, the value you get from computing latest server response + all pending mutations stays perfectly in balance.
There are some places where optimistic updates don’t make sense or are impossible. For example, you can’t really use them when applying filters to the search page, since you don’t know what the new order of results will be. But when they can be used, I believe they usually should.
One of my favorite features in the CMS is being able to quickly change between the editor and the live preview as you work. When you write a new paragraph in the WYSIWYG editor and switch to preview mode, it will show up instantly.
This is due to the pairing of Convex and React.
The core primitives of Convex are queries and mutations. When a mutation is performed, any query whose value is changed is automatically recomputed, and the new value is sent down to any client subscribing to that query. When the subscribing React component sees the query value change, it re-renders, showing the user the new value.
While pub/sub isn’t new, what makes this special is:
That last one cannot be overstated. Requesting the value from a query that hasn’t changed since its last call doesn’t run the query again, it just sends back the last computed value. No more headaches over cache invalidation, they handle it for you. It reduces costs (since Convex doesn’t charge for cache hits), and reduces latency for users.
That’s about it. I’m very happy with how this project turned out, and just wanted to nerd out over the details.
It troubles me that while every site has the potential to be this fast, most aren’t, and never will be. Most sites these days are “fine”, but I think fine is unacceptable in a world where great is possible.
Gardener’s Supply Company declared bankruptcy in August 2025 and was acquired by a company called Garden’s Alive, who decided to move our catalog over to Shopify. Their other subsidiaries were all on Shopify, so having everything under one roof makes plenty of sense. Though it was unfortunate for me and the other engineers working on the site, as we were laid off during the acquisition. ↩
You can use any validator that meets the Standard Schema spec. ↩
This component is heavily inspired by Next Faster, an e-commerce template created by Ethan Niser, Rhys Sullivan, and Arman Kumaraswamy . Their project was a huge inspiration for the name “Start Faster”, as well as the creation of the project itself. ↩
To be honest, I’m not sure if it would actually decrease INP inside of any benchmark, but the time between the user’s brain sending the signal to click and the event actually completing is certainly shorter. ↩