Build applications with a Headless CMS architecture

Vercel is the industry-standard platform for frontend frameworks (Next.js). It handles hosting, edge caching, and serverless functions. Baserow acts as the structured backend, managing the content, inventory, or user data that powers the frontend.

In an enterprise environment, separating the frontend (Vercel) from the data layer (Baserow) allows for a Headless Architecture. This gives you the best of both worlds:

  • Performance: Vercel handles global content delivery (CDN), caching, and scaling.
  • Usability: Baserow provides an easy-to-use editing interface for non-technical team members (HR, Marketing) to manage content without touching the codebase.

Architecture

We will connect Vercel to Baserow using the REST API to create a universal bridge.

  • Read (GET): Vercel fetches listings from Baserow to render the page (e.g., Job Board, Blog, Directory).
  • Write (POST): When a user submits a form, Vercel Serverless Functions securely sanitize and forward the data to Baserow.

Prerequisites

  • Baserow: A Database Token with Read permissions (for content) and Create permissions (for submissions).
  • Vercel: An existing Next.js project connected to a Git repository.
  • Environment Variables:
    • BASEROW_API_TOKEN: Your raw API key.
    • BASEROW_API_URL: https://api.baserow.io (or your self-hosted URL).
    • TABLE_ID: The ID of the specific table you are accessing.

Use Cases

Area In Vercel (The Frontend) In Baserow (The Backend)
Recruitment (ATS) Displays the Careers page. Renders job descriptions and application forms dynamically using Server-Side Rendering (SSR) or Static Site Generation (SSG). Stores job titles, descriptions, status (Open/Closed), and receives incoming applicant data (Name, Resume link).
Product Inventory A fast e-commerce storefront. Fetches product details and stock status at build time for maximum SEO performance. Manages SKUs, pricing, stock levels, and product images.
Programmatic SEO Generates thousands of landing pages (e.g., “Best Lawyers in [City]”) based on URL patterns. Stores the structured datasets (Cities, Professions, Metadata) that populate these pages.
Internal Tools A custom employee dashboard for expense reporting or leave requests protected by Vercel’s edge authentication. Acts as the system of record for approved expenses and employee logs.

Replace hardcoded data with live Baserow data in a Next.js app

We will show you how to pipe map data from Baserow into your UI. These universal steps apply whether you are building a blog, a job board, or a directory.

1. Set up the Baserow Database

Start by building the data structure.

  1. Log in to Baserow and create a new database from the Applicant Tracker template.
  2. Identify the Table IDs for:
    • Positions: Contains “Title”, “Department (Single Select)”, “Description”, and “Is filled (Boolean)”.
    • Applicants: Contains “Name”, “Email”, “CV (File)”, and “Applying for (Link to Positions)”.
  3. Create a Database Token (Settings → Database Tokens) with:
    • Read permissions for the Positions table.
    • Create permissions for the Applicants table.

Never hardcode your API keys in your frontend code. Vercel handles secrets securely.

2. Configure Environment Variables in Vercel

This allows Vercel to fetch data from Baserow during the build.

  1. Go to your Vercel Project Dashboard.
  2. Navigate to SettingsEnvironment Variables.
  3. Add the following:
    • BASEROW_API_TOKEN: Your generated database token (e.g., eyJ...). Just the raw key, without "Token " prefix, so it matches the code below.
    • BASEROW_API_URL: https://api.baserow.io (or your self-hosted URL).
    • POSITIONS_TABLE_ID: The ID of your Positions table.
    • APPLICANTS_TABLE_ID: The ID of your Applicants table.

Click Save. A redeploy is required for these to take effect.

3. Create the Universal Connector

Now we build the bridge. Instead of writing fetch requests inside every single page component (which is messy and hard to maintain), we will add a reusable function that connects your Next.js app to Baserow.

Create (or update) a file in your library folder where data fetching happens (e.g. lib/api.ts or lib/api.js).

In this case, we filter specifically for jobs where Is filled is false, and extract the results array from the Baserow response so the frontend receives a clean list.

Add the following code to the bottom of the file. This allows you to keep your existing template logic while adding Baserow capabilities:

// --- BASEROW CONNECTORS ---

// 1. SECURITY: Centralizes authentication so we don't repeat the API Token everywhere.
const getBaserowHeaders = () => {
  return {
    Authorization: `Token ${process.env.BASEROW_API_TOKEN}`,
  };
};

// 2. LIST FETCHER: Gets all rows from any table you specify.
export async function getBaserowData(tableId: string) {
  const baseUrl = process.env.BASEROW_API_URL;
  
  // Baserow API filter for Boolean fields where 'Is filled' is FALSE
  const query = new URLSearchParams({
    user_field_names: 'true',
    filters: '{"filter_type":"AND","filters":[{"type":"boolean","field":"Is filled","value":"0"}]}'
  });

  // We add 'user_field_names=true' so we get "Title" instead of "field_123"
  const res = await fetch(
    `${baseUrl}/api/database/rows/table/${tableId}/?${query}`,
    {
      headers: getBaserowHeaders(),
      next: { revalidate: 60 }, // ISR: Caches data but auto-updates every 60 seconds
    }
  );

  if (!res.ok) {
    throw new Error(`Failed to fetch data from table ${tableId}`);
  }

  const data = await res.json();
  
  // OPTIONAL ROUTING: Maps the internal Baserow Row ID to a 'slug' for Next.js URLs.
  return data.results.map((item: any) => ({
    ...item,
    slug: item.id.toString(), 
  }));
}

// Helper to get a Single Row (for Detail Pages)
export async function getBaserowRecord(recordId: string, tableId: string) {
  const baseUrl = process.env.BASEROW_API_URL;

    const res = await fetch(
    `${baseUrl}/api/database/rows/table/${tableId}/${recordId}/?user_field_names=true`,
    {
      headers: getBaserowHeaders(),
      next: { revalidate: 60 },
    }
  );

  if (!res.ok) {
    return null;
  }

  return res.json();
}

In Next.js 15+, ensure you await your params before passing them to the fetcher.

It handles the authentication headers and sets up the caching strategy automatically: one to get the list and one to get a single row.

4. Connect the Page to Baserow (Read Operation)

Now open the page where you want to display your data (e.g., app/page.tsx).

We are going to perform a data swap. We will locate the existing data in your template and replace it with the live function we just created.

The concept is to find the variable holding your data and replace it with an await call to your new connector.

A. Import the Connector

import { getBaserowData } from "@/lib/api"; // ✅ Import the universal connector

B. Swap the Data Source

In your main component, find where the data is defined. It may look like a static array const items = [...] or a local fetch.

Replace that line with an asynchronous call to your new function to fetch live data from Baserow during the build.

export default async function Index() {
  // ✅ Use the universal function with your specific Table ID
  const jobs = await getBaserowData(process.env.POSITIONS_TABLE_ID!);

  return (
    // ... your existing JSX ...
  )
}

C. Mapping Your Fields (Update Loop)

This is the most common stumbling block when moving from a JSON file to a database. When your template iterates over the data, you need to ensure the field names match your Baserow columns exactly.

Object Handling:

In a simple JSON file, Department might just be the text “Engineering”.

Baserow is a relational database, not a flat CSV. Complex field types like Single Select (e.g., Department) or Files are returned as objects, not strings, so they can carry extra metadata like color tags.

Baserow API response for Department Single Select field:

"Department": {
  "id": 55,
  "value": "Engineering",
  "color": "blue"
}

If you try to render an object directly in your JSX (e.g., {job.Department}), React will throw an error because it cannot render an object. You must point to the specific .value property.

Case Sensitivity: If your Baserow field is named “Title”, you must use job.Title in your code. job.title will return undefined.

Conditional Rendering

Here is how to safely handle this inside your map loop. We check if the data is an object (from Baserow) or just text (from your old data), so your app doesn’t break during the transition.

Update your app/page.tsx:

{jobs.map((job: any) => {
  // Baserow "Single Select" fields return an object: { id: 1, value: "Engineering", color: "blue" }
  // Standard text fields just return a string: "Engineering"
  // We use this check to handle both cases safely.
  const departmentName = 
    typeof job.Department === "object" && job.Department !== null
      ? job.Department.value 
      : job.Department;

  return (
    <div key={job.id}>
       <h2>{job.Title}</h2>
       
       {/* Render the clean value, not the raw object */}
       <span className="tag">{departmentName}</span> 
       
       {/* Use the Row ID for the link */}
       <Link href={`/posts/${job.id}`}>View Details</Link>
    </div>
  );
})}

Now that your application can Read the open positions, we want to build the input form to Write the candidate’s application back to Baserow.

5. Writing Data Back (Write Operation)

Reading data is safe to do publicly, but writing data requires strict security. You cannot place your BASEROW_API_TOKEN inside a frontend button or form component. If you do, any user can right-click “Inspect Element” and steal your database access.

To solve this, we use a Server-Side Proxy. Your frontend sends data to your own server (Vercel), and your server, which holds the secret keys, forwards it to Baserow.

In Next.js, we don’t need to spin up a separate backend server for this. We can use a Server Action. This allows you to write a function that looks like frontend code but runs securely on the server.

A: The Universal Write Connector

You can drop this logic into any page where you need to save data (e.g. src/app/posts/[slug]/page.tsx).

Locate the component where your form lives (e.g., your Contact page or Signup modal). Inside that component, paste this “Server Action” function.

The Infrastructure Code:

// ---------------------------------------------------------
  // THE SERVER ACTION (The Secure Bridge)
  // ---------------------------------------------------------
  async function submitToBaserow(formData: FormData) {
    'use server'; // <--- Critical: This ensures the code runs on Vercel, not the browser

    // 1. Capture data from your form
    const formField1 = formData.get("myFieldName");
    const formField2 = formData.get("myOtherFieldName");

    // 2. Configuration (Load from Environment Variables)
    const baseUrl = process.env.BASEROW_API_URL; 
    const targetTable = process.env.YOUR_TARGET_TABLE_ID;

    try {
      // 3. Send to Baserow
      const res = await fetch(`${baseUrl}/api/database/rows/table/${targetTable}/?user_field_names=true`, {
        method: "POST",
        headers: {
          "Authorization": `Token ${process.env.BASEROW_API_TOKEN}`,
          "Content-Type": "application/json",
        },
        // ---------------------------------------------------------
        // CUSTOMIZATION ZONE: Map your data here
        // Left Side: Exact Column Name in Baserow (Case Sensitive)
        // Right Side: The variable from step 1
        // ---------------------------------------------------------
        body: JSON.stringify({
          "Name": formField1,
          "Email": formField2,
          "Status": "New Submission" 
        }),
      });

      if (!res.ok) {
        throw new Error(`Baserow rejected the data: ${res.statusText}`);
      }

    } catch (error) {
      console.error("Submission Failed:", error);
      throw error; // This will trigger your error boundary or UI state
    }

    // 4. Success Action (Redirect or Refresh)
    // redirect("/success"); 
  }

You need to change the body: JSON.stringify({...}) and YOUR_TARGET_TABLE_ID to fit your use case.

B: Wiring it to your UI

Now that you have the infrastructure, you just need to trigger it. You don’t need complex API calls or useEffect hooks.

Find your existing HTML <form> tag and simply point the action attribute to the function you created above.

// Your existing UI code...
return (
    <div className="my-custom-form">
      <h1>Apply Now</h1>
      
      {/* Connect the form to the Server Action */}
      <form action={submitToBaserow} className="flex flex-col gap-4">
        
        {/* The 'name' attribute must match what you look for in Step 1 */}
        <input name="myFieldName" type="text" placeholder="Name" />
        <input name="myOtherFieldName" type="email" placeholder="Email" />
        
        <button type="submit">Submit Application</button>
      </form>
    </div>
);

6. Creating vs. Updating (Upsert Pattern)

The code above uses method: 'POST' which creates a new row.

If you want to update an existing row (e.g., marking a task as “Done”), you need to change your API Route:

  1. The Method: Change POST to PATCH.
  2. The URL: Append the Row ID to the URL: .../rows/table/${targetTable}/${rowId}/
    • Create: .../rows/table/123/
    • Update: .../rows/table/123/45/ (where 45 is the specific row to update).

To upload files to Baserow via API, POST the binary file to api/user-files/upload-file/ to get a file token, then POST that token to the row creation endpoint.

Alternative: Trigger Instant Rebuilds

By default, Next.js caches your content for speed. If you prefer your live site to update immediately whenever you change data in Baserow (instead of waiting for a cache refresh), you can use Vercel Deploy Hooks.

How to set it up:

  1. In Vercel: Go to Settings → Git → Deploy Hooks. Name it “Baserow Update” and click Create Hook. Copy the URL.
  2. In Baserow: Go to your Table → Webhooks. Create a new webhook, paste the Vercel URL, and check Row Created, Row Updated, and Row Deleted.

Now, every edit in Baserow forces Vercel to rebuild your site instantly.

This is suitable for low-traffic databases. Using Vercel Deploy Hooks triggers a full site rebuild. If your Baserow database changes frequently (e.g., 50 edits an hour), you will rebuild your site 50 times an hour, potentially slowing down the deployment queue.

Best Practices for Performance

1. Optimize Data Payload (Sanitization)

Baserow’s API returns the full row data by default. If your table has sensitive data, you must ensure this data never reaches the client’s browser.

Since Vercel runs server-side, you should sanitize the response in your library file before returning it to the frontend component.

return data.results.map(job => ({
  title: job.Title,
  description: job.Description,
  slug: job.id.toString()
  // Salary and Notes are explicitly excluded
}));

2. Handle Rate Limits with Caching

High-traffic enterprise sites should not hit the Baserow API on every single page view.

  • Static Rendering (Default): By default, Next.js fetches Baserow data at build time. This allows Vercel to serve your job board from a global CDN, ensuring sub-second load times and zero API load on Baserow during traffic spikes.
  • Incremental Static Regeneration (ISR): We implemented next: { revalidate: 60 } in our fetch call. The site is instant for users, but Vercel checks Baserow for updates in the background every 60 seconds.

3. Image Optimization

If your Baserow table contains images, the API returns a direct URL. Do not use the standard HTML <img> tag.

Use the Next.js <Image /> component. This automatically resizes, compresses, and converts images to modern formats (like WebP) on the fly.

Configuration:

You must add your Baserow domain to next.config.js to allow this optimization:

images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'files.baserow.io', // or your self-hosted domain
    },
  ],
},

4. Preview Mode

For content editors, waiting 60 seconds for ISR can be annoying. You can configure Vercel Preview Mode to bypass the cache and fetch live data from Baserow only for logged-in admin users. This gives you a Staging environment without needing a separate deployment.

5. Sending linked records and select fields

Baserow is a relational database. When customizing the body: JSON.stringify section to send data to Baserow, keep these rules in mind:

  1. Linked Records (Relations)

    If you are linking this new row to an existing row (e.g., linking an Applicant to a Position), Baserow expects an Array of IDs, not a single number or text: "Applying for": [15]

  2. Select Fields

    If you are writing to a “Single Select” field (like “Status”), you must send the text exactly as it appears in Baserow options (Case Sensitive), OR use the Select Option ID: "Status": "Active"

  3. Field Names:

    Always ensure the keys on the left side of your JSON (e.g., "Name", "Email") match your Baserow column headers exactly. If you renamed “Name” to “Full Name” in Baserow, you must update your code to "Full Name": formField1.

Summary

By connecting Vercel and Baserow via REST API, you create a separation of concerns:

  • Baserow becomes the user-friendly headless CMS.
  • Vercel delivers a sub-second, globally distributed frontend.
  • The API ensures data remains synchronized securely without manual CSV exports.

Files Changed:

  1. lib/api.ts: Updated to include the universal fetcher and headers.
  2. src/app/page.tsx: Updated to read and display the list of records.
  3. src/app/posts/[slug]/page.tsx: Updated to read a single record and handle the “Write” operation via Server Action.

Troubleshooting

Error Likely Cause The Fix
401 Unauthorized Missing or malformed Token. Ensure the header is Authorization: Token [KEY].
404 Not Found Wrong Table ID or Row ID. Check the URL of your Baserow table for the number.
400 Bad Request Field Name mismatch. Ensure the JSON keys match your Baserow column names exactly (Case Sensitive).

Still need help? If you’re looking for something else, please feel free to make recommendations or ask us questions; we’re ready to assist you.