Vercel Logo

Wire Next.js to FastAPI

The mock data has done its job. It proved the UI works, and now it's in the way. Time to pull it out and replace it with a real fetch.

Because both apps run under the same origin (thanks to vercel dev in the previous lesson, and thanks to Vercel's routing in production), the fetch URL is simple: just /api/items. No environment variables. No CORS. No absolute URLs for local vs. production.

Well, almost. One wrinkle with server components we'll handle in a second.

Outcome

Replace the mockItems array with an async fetch to /api/items that works both under vercel dev locally and in production.

Fast Track

  1. Replace mockItems with an async getItems() function
  2. Build the fetch URL from process.env.VERCEL_URL with a localhost fallback
  3. Make Home an async component

Hands-On

Why we can't just write /api/items

In a client component, fetch("/api/items") works perfectly. The browser knows the origin and fills in the rest. In a server component, there is no browser. The fetch runs on the server, and Node's fetch needs an absolute URL.

On Vercel, the platform auto-injects VERCEL_URL in every deployment. That's the hostname of the current deployment, e.g., hazel-home-abc123.vercel.app. For local dev, we fall back to http://localhost:3000.

Update the page component

Open starter/app/page.tsx and replace the file with this:

type Item = {
  id: number;
  name: string;
  category: string;
  price: number;
  in_stock: boolean;
};
 
async function getItems(): Promise<Item[]> {
  const base = process.env.VERCEL_URL
    ? `https://${process.env.VERCEL_URL}`
    : "http://localhost:3000";
  const res = await fetch(`${base}/api/items`);
  if (!res.ok) throw new Error("Failed to fetch items from Hazel Home API");
  return res.json();
}
 
export default async function Home() {
  const items = await getItems();
 
  return (
    <>
      <h2 className="text-2xl font-semibold mb-8">All Furniture</h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {items.map((item) => (
          <div
            key={item.id}
            className="bg-white border border-stone-200 rounded-lg p-6"
          >
            <p className="text-xs uppercase tracking-widest text-stone-400 mb-1">
              {item.category}
            </p>
            <h3 className="text-lg font-medium mb-3">{item.name}</h3>
            <div className="flex items-center justify-between">
              <span className="text-lg font-semibold">
                ${item.price.toLocaleString()}
              </span>
              <span
                className={`text-xs px-2 py-1 rounded-full ${
                  item.in_stock
                    ? "bg-emerald-50 text-emerald-700"
                    : "bg-stone-100 text-stone-400"
                }`}
              >
                {item.in_stock ? "In stock" : "Out of stock"}
              </span>
            </div>
          </div>
        ))}
      </div>
    </>
  );
}

Three things changed from the starter:

  1. The mockItems array is gone
  2. getItems() builds an absolute URL from VERCEL_URL (in production) or localhost:3000 (locally) and fetches from /api/items
  3. Home is now async so it can await the fetch

The component markup is identical. Same grid, same cards, same badges. Only the data source changed.

No NEXT_PUBLIC_ prefix needed

VERCEL_URL is a server-side variable. The fetch happens in a server component, not the browser, so we don't need to expose it as NEXT_PUBLIC_. If this fetch lived in a client component, we'd use a different pattern entirely, but server components keep things simple.

Run vercel dev and refresh

If vercel dev is still running from the last lesson, you should see the updated page after saving. If not, restart it:

cd starter
vercel dev

Open http://localhost:3000. The furniture listing appears, this time fetched live from the FastAPI server at http://localhost:3000/api/items.

If you stop vercel dev and reload, Next.js throws an error because getItems() can't reach the backend. That's expected. The two apps are genuinely connected now.

Try It

With vercel dev running, confirm the data is coming from FastAPI and not the mock:

  1. Open http://localhost:3000. All 8 items appear.
  2. Open http://localhost:3000/api/items in a second tab. Same data.
  3. Edit one item's name in api/index.py, save, and reload the frontend. The name updates.

The third check is the real proof. The frontend is now a window into the backend, not a static page.

Troubleshooting

Use the companion skill for quick checks

If you're working in academy-python-course, ask Cursor: "Use the python-on-vercel skill and check my starter/app/page.tsx and starter/api/index.py wiring." It can quickly spot route prefix mistakes, VERCEL_URL issues, and fetch URL problems.

TypeError: Failed to parse URL in the server logs: This happens if process.env.VERCEL_URL is unset and the fallback didn't kick in. Confirm the ternary is written correctly and that the fallback value starts with http://.

Failed to fetch items from Hazel Home API: vercel dev isn't running, or the FastAPI routes don't match. Check that api/index.py has @app.get("/api/items") (with the /api prefix) and that vercel dev is active on port 3000.

Done-When

  • page.tsx uses async function getItems() with no mockItems array
  • The fetch URL resolves to http://localhost:3000/api/items in local dev
  • http://localhost:3000 loads real data from FastAPI through vercel dev
  • Editing api/index.py and reloading updates what the frontend shows

Solution

// starter/app/page.tsx
type Item = {
  id: number;
  name: string;
  category: string;
  price: number;
  in_stock: boolean;
};
 
async function getItems(): Promise<Item[]> {
  const base = process.env.VERCEL_URL
    ? `https://${process.env.VERCEL_URL}`
    : "http://localhost:3000";
  const res = await fetch(`${base}/api/items`);
  if (!res.ok) throw new Error("Failed to fetch items from Hazel Home API");
  return res.json();
}
 
export default async function Home() {
  const items = await getItems();
 
  return (
    <>
      <h2 className="text-2xl font-semibold mb-8">All Furniture</h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {items.map((item) => (
          <div key={item.id} className="bg-white border border-stone-200 rounded-lg p-6">
            <p className="text-xs uppercase tracking-widest text-stone-400 mb-1">{item.category}</p>
            <h3 className="text-lg font-medium mb-3">{item.name}</h3>
            <div className="flex items-center justify-between">
              <span className="text-lg font-semibold">${item.price.toLocaleString()}</span>
              <span className={`text-xs px-2 py-1 rounded-full ${item.in_stock ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-400"}`}>
                {item.in_stock ? "In stock" : "Out of stock"}
              </span>
            </div>
          </div>
        ))}
      </div>
    </>
  );
}

Was this helpful?

supported.