Building a Personal Design Dashboard With Notion API and Railway: A UK Freelancer’s Weekend Project Walkthrough

·

,

I built this on a rainy Saturday in February, mostly out of frustration with juggling four browser tabs to answer one client question. A Notion database for projects here, a spreadsheet for invoices in GBP over there, a folder structure that made sense three months ago and now just feels like archaeology. The result was a self-hosted Railway app that pulls everything through the Notion API into a single, genuinely useful dashboard. It took a weekend. Here’s exactly how I did it.

Before we get into the code, a quick note on why Railway specifically. For UK-based freelancers who want a self-hosted feel without maintaining a VPS at 2am, Railway’s free tier (and its £5/month hobby plan) is a reasonable middle ground. You get persistent deploys, environment variables, and a Postgres instance if you need one. It’s not perfect, but it’s fast to set up and the deployment pipeline is git-push simple. If you want a deeper look at framework choices for self-hosted tooling, the open source alternatives UK indie developers are actually shipping with article on this blog covers the broader landscape well.

Developer workspace with a Notion API dashboard for freelancers displayed on a laptop screen
Photo by cottonbro studio on Pexels

Setting up your Notion API integration

Log into Notion, head to notion.so/my-integrations, and create a new integration. Give it a sensible name, something like “Freelance Dashboard”, and grab the Internal Integration Token. Store it somewhere safe. You’ll need it as an environment variable in Railway shortly.

Now, the important bit that trips people up: Notion integrations don’t automatically have access to your databases. You have to share each database with the integration manually. Open the database you want to connect, click the three-dot menu in the top right, go to “Add connections”, and select your integration. Do this for every database you plan to query. I have three: one for client projects, one for invoices, and one for design asset pipelines.

Your database IDs live in the URL. When you open a full-page database in Notion, the URL looks like notion.so/yourworkspace/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX?v=.... The 32-character string before the query parameter is your database ID. Copy these out and store them alongside your token.

Structuring your Notion databases for the API

The Notion API returns data in a fairly verbose JSON structure, so it pays to keep your databases clean before you start querying them. For the client projects database, I use these properties: Client Name (title), Status (select: Active, On Hold, Complete), Start Date (date), Deadline (date), Project Value (number, formatted as GBP), and Brief (rich text). The API will hand all of these back as typed objects, which makes parsing straightforward.

For invoices, I track: Invoice Number (title), Client (relation to the projects database), Amount (number), VAT (number), Paid (checkbox), Issue Date (date), and Due Date (date). The relation property is particularly useful because it lets you join data across databases in your app layer without building a separate relational database. Notion is doing that join for you, and the API respects it.

The asset pipeline database is simpler: Asset Name (title), Project (relation), Type (select: Logo, UI Kit, Illustration, Icon Set), Status (select: Draft, Review, Approved, Delivered), and File Link (URL).

Node.js code for a Notion API dashboard for freelancers shown in a terminal and editor
Photo by Daniil Komov on Pexels

Building the Railway app: Node.js + Express

I went with Node.js and Express here. Nothing exotic. Create a new project directory, run npm init -y, then install your dependencies:

npm install express @notionhq/client dotenv

The official @notionhq/client package handles all the auth headers and request formatting. Create a .env file at the root:

NOTION_TOKEN=secret_yourTokenHere
PROJECTS_DB_ID=yourProjectsDatabaseId
INVOICES_DB_ID=yourInvoicesDatabaseId
ASSETS_DB_ID=yourAssetsDatabaseId
PORT=3000

Now the core of the app. Create index.js:

require('dotenv').config();
const express = require('express');
const { Client } = require('@notionhq/client');

const app = express();
const notion = new Client({ auth: process.env.NOTION_TOKEN });

async function queryDatabase(databaseId, filter = {}) {
  const response = await notion.databases.query({
    database_id: databaseId,
    ...filter
  });
  return response.results;
}

app.get('/api/projects', async (req, res) => {
  try {
    const pages = await queryDatabase(process.env.PROJECTS_DB_ID);
    const projects = pages.map(page => ({
      id: page.id,
      client: page.properties['Client Name'].title[0]?.plain_text ?? 'Unnamed',
      status: page.properties['Status'].select?.name ?? 'Unknown',
      value: page.properties['Project Value'].number ?? 0,
      deadline: page.properties['Deadline'].date?.start ?? null
    }));
    res.json(projects);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.get('/api/invoices', async (req, res) => {
  try {
    const pages = await queryDatabase(process.env.INVOICES_DB_ID);
    const invoices = pages.map(page => ({
      id: page.id,
      invoiceNumber: page.properties['Invoice Number'].title[0]?.plain_text ?? '',
      amount: page.properties['Amount'].number ?? 0,
      vat: page.properties['VAT'].number ?? 0,
      paid: page.properties['Paid'].checkbox,
      dueDate: page.properties['Due Date'].date?.start ?? null
    }));
    res.json(invoices);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.get('/api/assets', async (req, res) => {
  try {
    const pages = await queryDatabase(process.env.ASSETS_DB_ID);
    const assets = pages.map(page => ({
      id: page.id,
      name: page.properties['Asset Name'].title[0]?.plain_text ?? '',
      type: page.properties['Type'].select?.name ?? 'Other',
      status: page.properties['Status'].select?.name ?? 'Draft'
    }));
    res.json(assets);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(process.env.PORT, () => {
  console.log(`Dashboard API running on port ${process.env.PORT}`);
});

Three endpoints, each pulling from a separate Notion database and shaping the response into something clean for the frontend to consume. The optional chaining throughout (?. and ??) is doing a lot of heavy lifting here; Notion properties can be null in ways the documentation doesn’t always warn you about.

Deploying to Railway

Initialise a git repository in your project folder and push it to GitHub. Then go to Railway, create a new project, and select “Deploy from GitHub repo”. Point it at your repo. Railway will detect the Node.js project automatically.

Add your environment variables under the project’s “Variables” tab. Everything from your .env file needs to live there; Railway injects them at runtime. Do not commit your .env file to GitHub. Add it to .gitignore before you push anything.

Add a start script to your package.json:

"scripts": {
  "start": "node index.js"
}

Railway picks this up automatically. Your first deploy will take about 90 seconds. After that, every git push triggers a new build. Railway gives you a public URL in the format yourproject.up.railway.app. Your three API endpoints are now live at that base URL.

Building the frontend dashboard

I kept the frontend deliberately simple: plain HTML, a tiny bit of vanilla JS, and the excellent charting libraries that UK SaaS teams are increasingly reaching for. I used Chart.js specifically for a revenue-by-month bar chart that pulls from the invoices endpoint and groups Amount + VAT totals by the month of the due date.

The key financial summary card calculates outstanding invoices (where paid === false) and displays total outstanding in GBP. No currency conversion, no dollar signs, just a clean £${total.toFixed(2)} string. Small thing, but it matters when you’re reporting income to HMRC.

Serve the static frontend from the same Express app using express.static('public') and drop your HTML, CSS, and JS files into a /public folder. One deploy, one URL, everything in one place.

A few things I learnt the hard way

Notion’s API rate limit is 3 requests per second. If you’re querying three databases on page load, stagger them or use Promise.all carefully. I cache responses for 60 seconds using a simple in-memory object to avoid hammering the API on every browser refresh.

Relation properties in the Notion API return page IDs, not the actual related page data. If you want the client name in your invoice response, you need a second API call to fetch that related page. I chose to keep it denormalised in the invoices database instead by adding a plain text “Client Name” field that I fill manually. Not elegant, but it halves the API calls.

Finally, if you’re dealing with sensitive client data in these databases, think carefully about what you expose through the Railway URL. I added a simple API key check middleware that validates a header against another environment variable. Nothing fancy, but enough to stop anyone who stumbles across the URL from reading your invoice totals. And speaking of due diligence with data, I was reminded recently whilst reviewing a client’s office refurbishment docs that the same careful approach applies to physical compliance paperwork; a colleague mentioned they’d used Asbestos Compliance Solutions for site documentation before any structural work began. Proper records, properly stored. Same principle, different domain.

This whole setup is genuinely more useful than I expected. The Notion API is well-documented, Railway removes all the ops friction, and the result is a dashboard that actually reflects how I work rather than forcing me to adapt to someone else’s product. If you want to push it further, look at adding webhook listeners to Notion page updates, or wiring in a Telegram bot that pings you when an invoice goes overdue. The foundation is there. Now go build something on a weekend.

Frequently Asked Questions

Is Notion's API free to use for personal freelance projects?

Yes, the Notion API is free to use and included with all Notion plans, including the free tier. You can make up to 3 requests per second, which is plenty for a personal dashboard querying a handful of databases.

What does Railway cost for hosting a small Node.js app like this?

Railway’s free tier gives you $5 of credit per month (applied to resource usage), which is usually enough for a low-traffic personal app. Their Hobby plan at around £5/month removes usage caps and adds persistent deployments with no idle spin-down, which is worth it for a dashboard you check daily.

Can I track invoices in GBP specifically through the Notion API?

Notion stores number properties without currency formatting at the data level, so your amounts are just numbers in the API response. You apply the GBP formatting in your frontend code using JavaScript’s Intl.NumberFormat with ‘en-GB’ locale and ‘GBP’ currency, which gives you properly formatted pound sterling strings.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *