Skip to main content

Overview

Flight Path is the Devarno blog, demonstrating how to integrate editorial content into V01T. It shows:
  • Storing blog articles as ArtefactData
  • Managing article metadata and publishing workflow
  • Serving articles via a public API
  • Rendering articles in a Next.js frontend with Notion content

Data Structure

Article Metadata

Each blog article is stored as an ArtefactData record. The source is the MOCK_ARTICLES array in seed_flight_path.py:
interface Article {
  id: string;              // Notion page ID
  slug: string;            // URL-safe identifier
  title: string;           // Article headline
  excerpt: string;         // Short summary
  publishedAt: string;     // ISO 8601 timestamp
  status: 'draft' | 'published' | 'archived';
  priority: number;        // Publication order (1 = featured)
  conversion_intent: 'engagement' | 'education' | 'lead_gen';
  business_value_score: number; // 0-10 relevance score
  word_count: number;
  readTime: string;        // e.g., "15min"
  tags: string[];          // Category tags
  keywords: string[];      // SEO keywords
  isPremium: boolean;      // Paywall flag
  author: {
    name: string;
    avatar: string;        // Image path
  };
}

ArtefactData Model

Each article is stored with:
FieldTypeDescription
titlestringArticle headline (e.g., “Building Skyflow 1.1”)
slugstringURL-safe identifier (e.g., “dje-skyflow-1.1”)
clusterstringAlways “flight-path-articles” (groups all blog posts)
raw_dataJSONFull article metadata
_metaobjectSystem metadata (external_id, last_synced)
Example:
{
  "title": "Building Skyflow 1.1",
  "slug": "dje-skyflow-1.1",
  "cluster": "flight-path-articles",
  "_meta": {
    "external_id": "23426de561ee801bb696ec8809c75950",
    "last_synced": "2025-03-11T10:00:00Z"
  },
  "raw_data": {
    "id": "23426de561ee801bb696ec8809c75950",
    "slug": "dje-skyflow-1.1",
    "title": "Building Skyflow 1.1",
    "excerpt": "Design Journal Entry #1: Take Off",
    "publishedAt": "2025-02-05T19:35:07.322Z",
    "status": "published",
    "priority": 1,
    "conversion_intent": "engagement",
    "business_value_score": 9.5,
    "word_count": 3500,
    "readTime": "15min",
    "tags": ["webdev", "devops", "infra", "marketing"],
    "keywords": ["skyflow", "social media analytics", "python development"],
    "isPremium": false,
    "author": {
      "name": "Devarno",
      "avatar": "/img/headshot.png"
    }
  }
}

Content Storage

Article content is stored separately in Notion. V01T stores only metadata (title, excerpt, tags, etc.). The actual article body is fetched from Notion via @notionhq/client at render time. This separation allows:
  • Content updates in Notion without re-seeding V01T
  • Caching metadata in V01T (fast list/search)
  • On-demand content fetching (reduces payload)
  • Version control of editorial metadata

Seeding Articles

Automatic Seeding

Use the management command to seed all articles:
python api/manage.py seed_flight_path
This:
  1. Creates the flight-path application (if it doesn’t exist)
  2. Creates a synthetic data source of type artefact
  3. Defines 4 blog articles from MOCK_ARTICLES
  4. Creates 4 ArtefactData records
  5. Stores full metadata in raw_data

Dry Run

Validate data without writing to the database:
python api/manage.py seed_flight_path --dry-run

Custom Articles

To integrate with a different article source (e.g., Airtable, Contentful), modify ARTICLES in the command or create a new seed command following the same pattern.

API Contract

List all articles

GET /trace/flight-path/artefact/
Response:
{
  "success": true,
  "data": [
    {
      "title": "Building Skyflow 1.1",
      "slug": "dje-skyflow-1.1",
      "cluster": "flight-path-articles",
      "_meta": { "external_id": "...", "last_synced": "..." },
      "excerpt": "Design Journal Entry #1: Take Off",
      "publishedAt": "2025-02-05T19:35:07.322Z",
      "status": "published",
      "priority": 1,
      "conversion_intent": "engagement",
      "business_value_score": 9.5,
      "word_count": 3500,
      "readTime": "15min",
      "tags": ["webdev", "devops", "infra", "marketing"],
      "keywords": ["skyflow", "social media analytics"],
      "isPremium": false,
      "author": {
        "name": "Devarno",
        "avatar": "/img/headshot.png"
      }
    },
    // ... 3 more articles
  ],
  "metadata": {
    "application": "flight-path",
    "component_type": "artefact",
    "total_count": 4,
    "filtered_count": 4,
    "page": 1,
    "page_size": 50,
    "total_pages": 1,
    "has_next": false,
    "has_previous": false
  },
  "timestamp": "2025-03-11T10:00:00Z"
}

Filter by status

GET /trace/flight-path/artefact/?search=published
Returns only published articles.

Search by keyword

GET /trace/flight-path/artefact/?search=skyflow
Matches title, excerpt, tags, and keywords.

Sort by priority

GET /trace/flight-path/artefact/?order_by=-priority
Order by priority descending (featured articles first).

Pagination

GET /trace/flight-path/artefact/?page=1&page_size=10

Frontend Integration

Using @v01t/client

Fetch article list:
import { createV01tClient } from '@v01t/client';

const client = createV01tClient({
  baseUrl: process.env.NEXT_PUBLIC_V01T_API_URL,
});

// Fetch published articles, sorted by priority
const response = await client.getArtefacts('flight-path', {
  page_size: 20,
  order_by: '-priority', // Featured first
});

const articles = response.data.map(artefact => ({
  id: artefact.raw_data.id,
  slug: artefact.slug,
  title: artefact.title,
  excerpt: artefact.raw_data.excerpt,
  readTime: artefact.raw_data.readTime,
  publishedAt: artefact.raw_data.publishedAt,
  author: artefact.raw_data.author,
  tags: artefact.raw_data.tags,
}));

In Next.js

The ArticlesService in devarno-landing uses V01T for article metadata:
// src/lib/articles.ts
export class ArticlesService {
  static async getArticles() {
    try {
      const response = await fetch(
        `${process.env.V01T_API_URL}/trace/flight-path/artefact/?page_size=50`,
        { next: { revalidate: 120 } } // 2-min cache
      );
      
      const json = await response.json();
      return json.data.map(convertArtefactToArticle);
    } catch (error) {
      // Fallback to mock articles
      return MOCK_ARTICLES;
    }
  }

  static async getArticle(slug: string) {
    const articles = await this.getArticles();
    return articles.find(a => a.slug === slug);
  }

  static async searchArticles(query: string) {
    const response = await fetch(
      `${process.env.V01T_API_URL}/trace/flight-path/artefact/?search=${encodeURIComponent(query)}`,
      { next: { revalidate: 120 } }
    );
    const json = await response.json();
    return json.data.map(convertArtefactToArticle);
  }
}

function convertArtefactToArticle(artefact: Artefact): Article {
  return {
    id: artefact.raw_data.id,
    slug: artefact.slug,
    title: artefact.title,
    excerpt: artefact.raw_data.excerpt,
    publishedAt: artefact.raw_data.publishedAt,
    status: artefact.raw_data.status,
    isPremium: artefact.raw_data.isPremium,
    readTime: artefact.raw_data.readTime,
    author: artefact.raw_data.author,
    tags: artefact.raw_data.tags,
    keywords: artefact.raw_data.keywords,
  };
}

Fetching Article Content

The blog page (src/app/blog/[slug]/page.tsx) fetches article metadata from V01T and content from Notion:
import { ArticlesService } from '@/lib/articles';
import { getNotionPage } from '@/lib/notion';

export default async function BlogPage({ params }: { params: { slug: string } }) {
  // Get metadata from V01T
  const article = await ArticlesService.getArticle(params.slug);
  if (!article) {
    notFound();
  }

  // Get content from Notion
  const content = await getNotionPage(article.id); // article.id = Notion page ID

  return (
    <article>
      <header>
        <h1>{article.title}</h1>
        <p>{article.excerpt}</p>
        <time>{new Date(article.publishedAt).toLocaleDateString()}</time>
        <span>{article.readTime}</span>
      </header>
      <RichText blocks={content.blocks} />
    </article>
  );
}

Metrics and Insights

Track article performance via V01T metadata:
// Example: Calculate average read time
const articles = await ArticlesService.getArticles();
const avgReadTime = articles.reduce((sum, a) => {
  const minutes = parseInt(a.readTime);
  return sum + minutes;
}, 0) / articles.length;

// Example: Filter by business value
const highValue = articles.filter(
  a => a.business_value_score >= 8
);

// Example: Group by intent
const byIntent = articles.reduce((acc, a) => {
  if (!acc[a.conversion_intent]) acc[a.conversion_intent] = [];
  acc[a.conversion_intent].push(a);
  return acc;
}, {} as Record<string, Article[]>);

Data Refresh

Manual Refresh

python api/manage.py seed_flight_path --skip-existing=false
This updates all existing records with fresh metadata.

Automated Sync (via n8n)

A scheduled n8n workflow can:
  1. Monitor a Google Sheets or Airtable “Article Roadmap”
  2. Trigger when status changes to published
  3. Call the seed command to add/update metadata
  4. Clear frontend cache (CDN purge, revalidate tags)
See Operations Runbooks for setup.

Publishing Workflow

Draft to Published

  1. Author writes in Notion
  2. Article status set to draft in metadata
  3. Editor reviews and promotes to published
  4. Seed command called: python api/manage.py seed_flight_path --skip-existing=false
  5. V01T metadata updated, frontend cache invalidated
  6. Article appears in blog list and search

Archive

Set status to archived to hide from public API (query with ?search=archived for admin only).

Cluster Architecture

All articles use cluster: "flight-path-articles". This allows:
  • Querying all articles with ?cluster=flight-path-articles
  • Grouping blog posts separately from other content types
  • Future multi-blog support (e.g., engineering vs. marketing blogs)