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.
The properties JSONB field stores kind-specific data:
{
"readTime" : 8 ,
"difficulty" : "intermediate" ,
"tags" : [ "react" , "typescript" , "tutorial" ],
"canonicalUrl" : "https://example.com/original-post" ,
"updatedContent" : "2024-03-15T10:30:00Z"
}
{
"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
# 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
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 );
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" );
}
Full-Text Search
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