Skip to main content

Stories

Stories are Aya’s content publishing system. They support articles, news, events, and other content types with full internationalization, Markdown/MDX authoring, and community interactions.

Story Types (Kinds)

Aya supports multiple story kinds for different content purposes:

Article

Long-form content, tutorials, blog posts

News

Announcements, updates, press releases

Event

Meetups, conferences, workshops with date proposals

Future Story Kinds

The architecture supports extending with new kinds:
  • Video: Video content with transcripts
  • Podcast: Audio content with show notes
  • Project: Showcase projects with demos
  • Job: Job postings and opportunities

Story Structure

Core Fields

apps/services/etc/data/default/migrations/0001_initial.sql
CREATE TABLE "story" (
  "id" CHAR(26) PRIMARY KEY,
  "slug" TEXT NOT NULL UNIQUE,
  "author_profile_id" CHAR(26) REFERENCES "profile",
  "kind" TEXT NOT NULL,                    -- 'article', 'news', 'event'
  "cover_picture_uri" TEXT,
  "properties" JSONB,                      -- Kind-specific metadata
  "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  "updated_at" TIMESTAMP WITH TIME ZONE,
  "deleted_at" TIMESTAMP WITH TIME ZONE,
  "published_at" TIMESTAMP WITH TIME ZONE  -- NULL = draft
);

Translations

All content is stored in the story_tx table for multi-language support:
CREATE TABLE "story_tx" (
  "story_id" CHAR(26) NOT NULL,
  "locale_code" CHAR(12) NOT NULL,         -- 'en', 'tr', 'pt-PT', etc.
  "title" TEXT NOT NULL,
  "summary" TEXT,                          -- Short description for cards
  "content" TEXT NOT NULL,                 -- Full Markdown/MDX content
  "properties" JSONB,                      -- Locale-specific metadata
  PRIMARY KEY ("story_id", "locale_code")
);
3-Tier Fallback: Like profiles, stories use intelligent locale fallback: requested locale → author’s default locale → any available locale.

Properties (Extensible Metadata)

The properties JSONB field stores kind-specific data:
Article properties
{
  "readTime": 8,
  "difficulty": "intermediate",
  "tags": ["react", "typescript", "tutorial"],
  "canonicalUrl": "https://example.com/original-post",
  "updatedContent": "2024-03-15T10:30:00Z"
}
Event properties
{
  "eventType": "online",
  "location": "Zoom Meeting",
  "capacity": 100,
  "registrationUrl": "https://example.com/register",
  "organizer": "Aya Community",
  "speakers": [
    {"name": "Jane Doe", "bio": "..."}
  ]
}

Markdown/MDX Content

Stories use Markdown with MDX extensions for rich, interactive content.

Basic Markdown

story_tx.content example
# Introduction to React Query

React Query is a powerful data synchronization library...

## Key Features

- Automatic caching
- Background refetching
- Optimistic updates

```tsx
import { useQuery } from '@tanstack/react-query';

function Profile({ userId }) {
  const { data } = useQuery(['user', userId], () => fetchUser(userId));
  return <div>{data.name}</div>;
}

MDX Components

Support for custom React components:
<Note>
  **Important**: Always configure staleTime for React Query.
</Note>

<CodeGroup>
  <code>typescript React</code>
  const { data } = useQuery(options);
  
  <code>go Go</code>
  profile, err := repo.GetBySlug(ctx, slug)
</CodeGroup>

Frontend MDX Compilation

src/lib/mdx.tsx
import { compile } from "@mdx-js/mdx";
import { Note, Warning, Tip, CodeGroup } from "@/components/userland";

// Map MDX component names to React components
const components = {
  Note,
  Warning,
  Tip,
  CodeGroup,
  // ... more components
};

export async function compileMDX(source: string) {
  const code = String(
    await compile(source, {
      outputFormat: "function-body",
      development: false,
    })
  );

  const { default: Component } = await run(code, {
    ...runtime,
    baseUrl: import.meta.url,
  });

  return () => <Component components={components} />;
}

Story Series

Group related stories into a series:
CREATE TABLE "story_series" (
  "id" CHAR(26) PRIMARY KEY,
  "slug" TEXT NOT NULL UNIQUE,
  "author_profile_id" CHAR(26) REFERENCES "profile",
  "cover_picture_uri" TEXT,
  "order_index" INTEGER[],               -- Ordered story IDs
  "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE "story_series_tx" (
  "story_series_id" CHAR(26),
  "locale_code" CHAR(12),
  "title" TEXT NOT NULL,
  "description" TEXT,
  PRIMARY KEY ("story_series_id", "locale_code")
);

CREATE TABLE "story_series_item" (
  "id" CHAR(26) PRIMARY KEY,
  "story_series_id" CHAR(26) REFERENCES "story_series",
  "story_id" CHAR(26) REFERENCES "story",
  "order" INTEGER NOT NULL
);
Example: “React Hooks Tutorial” series with parts 1-5.

Creating a Series

const series = await backend.createSeries(locale, {
  slug: "react-hooks-tutorial",
  title: "React Hooks Tutorial",
  description: "A comprehensive guide to React Hooks",
  authorProfileSlug: "eser",
});

// Add stories to series
await backend.addStoryToSeries(locale, series.slug, "react-hooks-part-1", 0);
await backend.addStoryToSeries(locale, series.slug, "react-hooks-part-2", 1);

Story Interactions

Users can interact with stories through various mechanisms:

Bookmarks

CREATE TABLE "story_interaction" (
  "id" CHAR(26) PRIMARY KEY,
  "story_id" CHAR(26) REFERENCES "story",
  "user_id" CHAR(26) REFERENCES "user",
  "kind" TEXT NOT NULL,                    -- 'bookmark', 'like', 'share'
  "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
// Bookmark a story
await backend.bookmarkStory(locale, storySlug);

// Get user's bookmarks
const bookmarks = await backend.getBookmarkedStories(locale);

Discussions (Comments)

Each story has a discussion thread:
// Get story discussion
const discussion = await backend.getStoryDiscussion(locale, storySlug);

// Create comment
await backend.createStoryComment(locale, storySlug, {
  content: "Great article! Thanks for sharing.",
  parentCommentId: null, // Top-level comment
});

// Reply to comment
await backend.createStoryComment(locale, storySlug, {
  content: "Glad you found it helpful!",
  parentCommentId: commentId,
});

// Vote on comment
await backend.voteComment(locale, commentId, "up");

Date Proposals (Events Only)

For event stories, users can propose and vote on dates:
CREATE TABLE "story_date_proposal" (
  "id" CHAR(26) PRIMARY KEY,
  "story_id" CHAR(26) REFERENCES "story",
  "user_id" CHAR(26) REFERENCES "user",
  "proposed_date" TIMESTAMP WITH TIME ZONE,
  "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE "story_date_proposal_vote" (
  "story_date_proposal_id" CHAR(26) REFERENCES "story_date_proposal",
  "user_id" CHAR(26) REFERENCES "user",
  "vote" INTEGER,                          -- 1 = upvote, -1 = downvote
  PRIMARY KEY ("story_date_proposal_id", "user_id")
);
// Propose a date for an event
await backend.proposeDateForEvent(locale, eventSlug, {
  proposedDate: "2024-05-15T18:00:00Z",
});

// Vote on a proposal
await backend.voteDateProposal(locale, proposalId, 1); // Upvote

// Get all proposals with vote counts
const proposals = await backend.getDateProposals(locale, eventSlug);
// [
//   { id: "...", proposedDate: "2024-05-15T18:00:00Z", votes: 23 },
//   { id: "...", proposedDate: "2024-05-22T18:00:00Z", votes: 15 },
// ]

Business Logic Examples

Get Story by Slug

pkg/api/business/stories/get.go
package stories

import (
    "context"
    "time"
)

func Get(
    ctx context.Context,
    repo Repository,
    localeCode string,
    slug string,
    userID *string,
) (*Story, error) {
    story, err := repo.GetBySlug(ctx, localeCode, slug)
    if err != nil {
        return nil, err
    }

    // Business rule: Don't show deleted stories
    if story.DeletedAt != nil {
        return nil, ErrStoryNotFound
    }

    // Business rule: Only show published stories to public
    // (Authors can see their own drafts)
    if story.PublishedAt == nil {
        isAuthor := userID != nil && story.AuthorUserID == *userID
        if !isAuthor {
            return nil, ErrStoryNotFound
        }
    }

    return story, nil
}

Publish Story

pkg/api/business/stories/publish.go
package stories

import (
    "context"
    "time"
)

func Publish(
    ctx context.Context,
    repo Repository,
    storyID string,
    userID string,
) (*Story, error) {
    story, err := repo.GetByID(ctx, storyID)
    if err != nil {
        return nil, err
    }

    // Business rule: Only author can publish
    if story.AuthorUserID != userID {
        return nil, ErrUnauthorized
    }

    // Business rule: Can't publish deleted stories
    if story.DeletedAt != nil {
        return nil, ErrStoryDeleted
    }

    // Business rule: Already published
    if story.PublishedAt != nil {
        return story, nil
    }

    now := time.Now()
    story.PublishedAt = &now
    story.UpdatedAt = &now

    if err := repo.Update(ctx, story); err != nil {
        return nil, err
    }

    return story, nil
}

Frontend Patterns

Story Route with SSR

src/routes/$locale/stories/$slug/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { storyQueryOptions, storyDiscussionQueryOptions } from "@/modules/backend/queries";
import { compileMDX } from "@/lib/mdx";

export const Route = createFileRoute("/$locale/stories/$slug/")({
  loader: async ({ params, context }) => {
    const { locale, slug } = params;

    // Ensure story data for head()
    const story = await context.queryClient.ensureQueryData(
      storyQueryOptions(locale, slug)
    );

    // Prefetch discussion
    await context.queryClient.prefetchQuery(
      storyDiscussionQueryOptions(locale, slug)
    );

    return { locale, slug, story };
  },

  head: ({ loaderData }) => ({
    title: `${loaderData.story.title} - Aya`,
    meta: [
      { name: "description", content: loaderData.story.summary },
      { property: "og:title", content: loaderData.story.title },
      { property: "og:description", content: loaderData.story.summary },
      { property: "og:image", content: loaderData.story.coverPictureUri },
      { property: "article:published_time", content: loaderData.story.publishedAt },
      { property: "article:author", content: loaderData.story.authorProfile.title },
    ],
  }),
});

function StoryPage() {
  const { locale, slug } = Route.useParams();
  const { data: story } = useSuspenseQuery(storyQueryOptions(locale, slug));
  const { data: discussion } = useSuspenseQuery(storyDiscussionQueryOptions(locale, slug));

  const [ContentComponent, setContentComponent] = useState<React.ComponentType | null>(null);

  useEffect(() => {
    compileMDX(story.content).then(setContentComponent);
  }, [story.content]);

  return (
    <article>
      <header>
        <h1>{story.title}</h1>
        <p>{story.summary}</p>
        <div>
          By <a href={`/${locale}/${story.authorProfile.slug}`}>
            {story.authorProfile.title}
          </a>
        </div>
        <time dateTime={story.publishedAt}>
          {formatDate(story.publishedAt, locale)}
        </time>
      </header>

      {story.coverPictureUri && (
        <img src={story.coverPictureUri} alt={story.title} />
      )}

      <div className="prose">
        {ContentComponent && <ContentComponent />}
      </div>

      <footer>
        <h2>Discussion</h2>
        <DiscussionThread discussion={discussion} locale={locale} />
      </footer>
    </article>
  );
}

export default StoryPage;

Story Editor

src/components/forms/story-editor.tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { backend } from "@/modules/backend/backend.ts";
import { storiesQueryOptions } from "@/modules/backend/queries";

type Props = {
  locale: string;
  profileSlug: string;
  initialStory?: Story;
};

export function StoryEditor(props: Props) {
  const [title, setTitle] = useState(props.initialStory?.title ?? "");
  const [summary, setSummary] = useState(props.initialStory?.summary ?? "");
  const [content, setContent] = useState(props.initialStory?.content ?? "");
  const [kind, setKind] = useState(props.initialStory?.kind ?? "article");

  const queryClient = useQueryClient();

  const saveMutation = useMutation({
    mutationFn: () => {
      if (props.initialStory !== undefined) {
        return backend.updateStory(props.locale, props.initialStory.slug, {
          title,
          summary,
          content,
        });
      }
      return backend.createStory(props.locale, props.profileSlug, {
        slug: generateSlug(title),
        kind,
        title,
        summary,
        content,
      });
    },
    onSuccess: () => {
      queryClient.invalidateQueries(storiesQueryOptions(props.locale).queryKey);
    },
  });

  return (
    <form onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }}>
      <select value={kind} onChange={(e) => setKind(e.target.value)}>
        <option value="article">Article</option>
        <option value="news">News</option>
        <option value="event">Event</option>
      </select>

      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Story title"
      />

      <textarea
        value={summary}
        onChange={(e) => setSummary(e.target.value)}
        placeholder="Short summary"
        rows={3}
      />

      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Markdown content"
        rows={20}
        className="font-mono"
      />

      <button type="submit" disabled={saveMutation.isPending}>
        {saveMutation.isPending ? "Saving..." : "Save Draft"}
      </button>
    </form>
  );
}

function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

Story Routing

  • /{locale}/articles - Article listing
  • /{locale}/news - News listing
  • /{locale}/stories/{slug} - Story detail
  • /{locale}/series/{slug} - Series detail
  • /{locale}/{profile-slug}/stories - Profile’s stories
  • /{locale}/{profile-slug}/stories/{story-slug} - Story under profile

Cover Image Generation

Aya includes a cover image generator for stories without custom covers:
src/lib/cover-generator/generator.ts
import { createCanvas } from "canvas";

export function generateCover(
  title: string,
  authorName: string,
  gradient: [string, string],
): Buffer {
  const canvas = createCanvas(1200, 630);
  const ctx = canvas.getContext("2d");

  // Gradient background
  const grad = ctx.createLinearGradient(0, 0, 1200, 630);
  grad.addColorStop(0, gradient[0]);
  grad.addColorStop(1, gradient[1]);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, 1200, 630);

  // Title text
  ctx.fillStyle = "#ffffff";
  ctx.font = "bold 64px Inter";
  ctx.fillText(title, 60, 300);

  // Author text
  ctx.font = "32px Inter";
  ctx.fillText(`by ${authorName}`, 60, 500);

  return canvas.toBuffer("image/png");
}
Stories support PostgreSQL full-text search:
etc/data/default/migrations/0005_fts_search.sql
ALTER TABLE "story_tx" ADD COLUMN "search_vector" tsvector;

CREATE INDEX story_tx_search_idx ON "story_tx" USING gin("search_vector");

CREATE FUNCTION story_tx_search_update() RETURNS trigger AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(NEW.summary, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(NEW.content, '')), 'C');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER story_tx_search_update_trigger
BEFORE INSERT OR UPDATE ON "story_tx"
FOR EACH ROW EXECUTE FUNCTION story_tx_search_update();
// Search stories
const results = await backend.searchStories(locale, "react hooks tutorial");

Next Steps

Internationalization

Learn about multi-language content and fallback patterns

Frontend Development

Build story UI with MDX compilation and React Query

Backend Development

Implement story business logic and database queries

Database Guide

Work with PostgreSQL migrations and sqlc

Build docs developers (and LLMs) love