Build an AI Content Vault Mobile App
Build a mobile app that uses AI to auto-categorize anything you share to it.
Introduction
30 Second Summary
You save links, photos, recipes, and video recommendations from a dozen different apps every day. But finding that one article or that one Instagram reel a week later feels impossible because everything is scattered across bookmarks, screenshots, and chat threads.
In this project, you will build a mobile app that receives shared content from any app on your phone and uses AI to automatically categorize, tag, and organize it into a searchable personal vault.
What You'll Build
A full-stack mobile content vault that integrates with Android's share sheet, processes content through a serverless AI pipeline, and displays everything in a browsable, filterable feed.
By the end of this project, you'll have:
- A working React Native mobile app that receives shared links, images, and text from any Android app via the system share sheet.
- A Supabase Edge Function that calls Gemini to automatically categorize, tag, and summarize shared content including social media and video URLs.
- A browse interface that displays all saved items organized by AI-generated categories with tag filtering and search.
- Secret Mission: Add multimodal AI vision analysis to intelligently categorize any shared image based on visual content.
Are there any prerequisites?
This project assumes basic familiarity with React Native and Expo concepts, JavaScript or TypeScript, and SQL fundamentals.
You will also need an Android physical device to test the share intent functionality.
Set Up Your Project
To build a mobile app that receives shared content from other apps, you need native code running on your Android device. This means setting up an Expo project with a development build rather than using Expo Go.
In this step, you will create your Expo SDK 56 project, connect it to a Supabase backend, and produce an installable APK on your Android phone using EAS Build.
In this step, get ready to:
- Create an Expo SDK 56 project and install share intent dependencies.
- Create a Supabase account and project for your backend.
- Build and install a development APK on your Android device.
Create the Expo project and install dependencies
Expo SDK 56 requires a development build because Expo Go is not available for SDK 56 on app stores. This works in your favor because the share intent feature also requires native code that only a development build can provide.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Expo SDK 56 project by running this command:
npx create-expo-app@latest --template default@sdk-56 AIContentVault
What does this command do?
The npx create-expo-app@latest command scaffolds a new Expo project. The --template default@sdk-56 flag ensures you get SDK 56 specifically, since the default template may create an older SDK version during the transition period.
- The CLI may prompt you to confirm the project name or install packages. Press Enter to accept defaults.
- Move into the new project folder by running:
cd AIContentVault
- Install the share intent package by running:
npm install expo-share-intent
- Install expo-linking (required by expo-share-intent since Expo 52) by running:
expo install expo-linking
- Install the Supabase client library by running:
npm install @supabase/supabase-js
Why three separate packages?
Each package serves a distinct role. expo-share-intent adds native Android intent filters so your app appears in the system share sheet. expo-linking handles deep link routing that share intents rely on.
Finally, @supabase/supabase-js connects your app to the Supabase backend where your data and Edge Functions live.
- Confirm all packages installed successfully by running:
ls node_modules | grep -E "expo-share-intent|expo-linking|supabase"
You should see expo-share-intent, expo-linking, and @supabase listed in the output.
Package install failed?
Make sure you are inside the AIContentVault folder before running install commands. Run pwd to check your current directory.
If you see permission errors, try running the install again without sudo. Node packages should install in user space.
Help me troubleshoot
Now configure the share intent plugin in your project. This tells Expo which content types your app can receive from the Android share sheet.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the AIContentVault folder on your Desktop, then click Open.
- In the Explorer sidebar, click app.json to open it.
- Replace the entire contents of app.json with this configuration:
{
"expo": {
"name": "AIContentVault",
"slug": "ai-content-vault",
"version": "1.0.0",
"scheme": "aicontentvault",
"platforms": ["android"],
"android": {
"package": "com.yourname.aicontentvault"
},
"plugins": [
[
"expo-share-intent",
{
"androidIntentFilters": ["text/*", "image/*"],
"androidMultiIntentFilters": ["text/*", "image/*"]
}
]
]
}
}
What does this configuration do?
The scheme field registers a custom URL scheme for deep linking. The platforms array limits the build to Android only.
The plugins section configures expo-share-intent. The androidIntentFilters tell Android your app can receive text (URLs, plain text) and images. The androidMultiIntentFilters enable receiving multiple items at once.
- Save app.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing a JSON syntax error?
Make sure there are no trailing commas after the last item in any array or object. JSON does not allow trailing commas like JavaScript does.
Check that you replaced the entire file contents, not just pasted inside the existing config.
Help me fix my app.json
✔️ Awesome, I've got everything!
Great. Make sure you have saved app.json before moving on.
ⓧ I'd like to double check the full code
Here is the complete app.json for comparison:
{
"expo": {
"name": "AIContentVault",
"slug": "ai-content-vault",
"version": "1.0.0",
"scheme": "aicontentvault",
"platforms": ["android"],
"android": {
"package": "com.yourname.aicontentvault"
},
"plugins": [
[
"expo-share-intent",
{
"androidIntentFilters": ["text/*", "image/*"],
"androidMultiIntentFilters": ["text/*", "image/*"]
}
]
]
}
}
Create a Supabase account and project
Supabase provides the database and serverless Edge Functions that will power your AI categorization pipeline. The free tier includes 500 MB of database storage and 500,000 edge function invocations.
- Open your browser and go to supabase.com.
- Click Start your project to create a free account.
- Sign up using your GitHub account or email.
- Once logged in, click New Project.
- Enter a project name like ai-content-vault.
- Set a database password. Save this somewhere safe.
- Choose the region closest to you.
- Click Create new project.
- Wait for the project to finish provisioning (this takes about 1-2 minutes).
Once your project is ready, you need to note two values that your app will use to connect to Supabase.
- In the Supabase dashboard, click Settings in the left sidebar.
- Click API.
- Copy the Project URL value and save it in a text file or notes app.
- Copy the anon public key from the Project API keys section and save it alongside the URL.
What are these values for?
The Project URL is the address of your Supabase instance. The anon key is a public key that allows your app to communicate with Supabase.
It is safe to include in client-side code because Row Level Security policies control what data it can access.
Now install the Supabase CLI locally so you can create and deploy Edge Functions from your terminal.
- Switch back to your terminal.
- Make sure you are inside the AIContentVault folder.
- Install the Supabase CLI as a dev dependency by running:
npm install supabase --save-dev
- Verify the CLI is accessible by running:
npx supabase --version
You should see a version number printed in the terminal.
Supabase CLI not working?
If npx supabase --version fails, make sure the install completed without errors. Run npm install supabase --save-dev again and watch for error output.
The Supabase CLI requires Node.js 20 or later. You have Node.js 22.13+ which satisfies this requirement.
Help me debug the Supabase CLI install
Set up EAS Build and create your development APK
EAS Build compiles your Expo project in the cloud and produces an APK you can install directly on your Android device. You need this because expo-share-intent relies on native Android code that Expo Go cannot provide.
- Install EAS CLI globally by running:
npm install --global eas-cli
- Verify the install by running:
eas --version
You should see a version number in the output.
eas command not found?
If your terminal cannot find eas, your global npm bin directory may not be in your PATH. Close and reopen your terminal, then try again.
Alternatively, you can run EAS commands with npx eas-cli instead of eas.
Help me fix the eas command
- Log in to your Expo account by running:
eas login
- The CLI will prompt you for your Expo account email and password. Enter them to authenticate.
- If you do not have an Expo account yet, create one at expo.dev first, then run the login command again.
- Configure your project for EAS Build by running:
eas build:configure
- The CLI will ask you to select a platform. Choose Android.
This creates an eas.json file in your project root with default build profiles.
What is eas.json?
The eas.json file defines build profiles. Each profile controls how your app is compiled. The development profile creates a debug build with developer tools enabled.
The distribution: internal setting means the build is for direct device installation rather than app store submission.
Now update eas.json to produce an APK file that you can sideload onto your Android device.
- In VS Code, open eas.json from the Explorer sidebar.
- Replace the entire contents with this configuration:
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
}
}
}
Why set buildType to apk?
By default, EAS Build produces an AAB (Android App Bundle) for Play Store distribution. Since you are installing directly on your device for development, you need an APK file instead.
The buildType: apk setting tells EAS to output a directly installable file.
- Save eas.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Make sure eas.json is saved before starting the build.
ⓧ I'd like to double check the full code
Here is the complete eas.json for comparison:
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
}
}
}
Time to run your first build. This compiles your project in the cloud and produces the APK.
- Switch back to your terminal.
- Start the development build by running:
eas build --platform android --profile development
- The CLI may ask you to confirm the Android application ID. Press Enter to accept com.yourname.aicontentvault (pulled from your app.json).
- When asked to generate a new Android Keystore, select Yes.
How long does the build take?
Your first EAS Build typically takes 10-15 minutes because it needs to set up the build environment. Subsequent builds are faster.
You can monitor progress in your terminal or at the URL that EAS prints.
- Once the build completes, EAS will print a URL to download the APK.
- Open the download URL on your Android device's browser.
- Download and install the APK. Your phone may ask you to allow installs from unknown sources.
- Open the installed app on your Android device to confirm it launches.
Build failed or APK won't install?
If the build fails, check the build logs at the URL EAS provides. Common causes include a missing android.package in app.json or a malformed eas.json.
If the APK won't install, make sure you have enabled Install from unknown sources in your Android settings for your browser app.
Help me troubleshoot my EAS build
Your development environment is ready. You have an Expo project with share intent support, a Supabase backend standing by, and a working development build on your phone. Next up, you will design the database schema and create the Edge Function that powers your AI categorization.
Build the Database Schema and Edge Function
In the previous step you set up your Expo project, created a Supabase project, and installed a development build on your Android device.
Now it's time to build the backend that powers the AI categorization. When your app receives shared content, it needs somewhere to store it and something to process it. In this step you'll create a PostgreSQL database table and a Supabase Edge Function that calls Google Gemini to intelligently categorize everything you share.
In this step, get ready to:
- Design and deploy a PostgreSQL schema for storing AI-enriched content.
- Create a Supabase Edge Function that processes shared content with Gemini.
- Deploy the function, store secrets, and test the AI pipeline end-to-end.
Design and deploy the database schema
Every piece of content you share to the app needs a structured home. The saved_items table stores the original content alongside all the AI-generated metadata like category, tags, and summary.
- In the Supabase dashboard, click SQL Editor in the left sidebar.
- Click New Query.
- Create the saved_items table by pasting this SQL and clicking Run (or pressing Cmd+Enter on macOS, Ctrl+Enter on Windows):
CREATE TABLE saved_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_type TEXT NOT NULL CHECK (content_type IN ('link', 'text', 'image')),
original_content TEXT NOT NULL,
title TEXT,
description TEXT,
category TEXT,
tags TEXT[],
thumbnail_url TEXT,
ai_summary TEXT,
source_platform TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
What does this schema do?
Each column serves a specific role in the AI content pipeline:
- content_type constrains entries to link, text, or image so the Edge Function knows how to process each item.
- tags TEXT[] is a PostgreSQL array column that stores multiple AI-generated tags per item without needing a separate join table.
- source_platform tracks where the content came from (YouTube, Instagram, Chrome, etc.) so you can filter by source later.
- After clicking Run, you should see a success message at the bottom of the editor.
- Click Table Editor in the left sidebar to confirm saved_items now appears in your tables list.
Don't see the table?
Make sure you selected the correct project in the Supabase dashboard header. If the query reports a syntax error, check that you copied the full SQL block including the closing semicolon.
help me debug my SQL table creation
Create the Edge Function
A Supabase Edge Function is a serverless TypeScript function that runs on Deno close to your users. It keeps your Gemini API key server-side and handles URL fetching that would be blocked by CORS in a client context.
Why an Edge Function instead of calling Gemini directly from the app?
Embedding an API key in a mobile app bundle exposes it to anyone who decompiles the APK. An Edge Function keeps the key on the server.
It also lets you fetch external URLs and parse their HTML for metadata, which mobile apps cannot easily do due to CORS restrictions.
- In your terminal, make sure you are in your project's root folder. Then scaffold the Edge Function by running:
supabase functions new process-item
This creates supabase/functions/process-item/index.ts with starter boilerplate.
- Open supabase/functions/process-item/index.ts in VS Code and replace its entire contents with the code below.
The function is split into four sections. Start with the imports and configuration:
- Paste the imports and environment setup at the top of the file:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY')!;
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
What does this code do?
- Deno imports use URL-based modules instead of npm. The serve function creates an HTTP server, and createClient connects to your Supabase database.
- SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are automatically available in every Edge Function. You only need to manually set GEMINI_API_KEY.
- The service role key bypasses Row Level Security so the function can insert records without user authentication.
- Below the config, add the fetchMetadata function that extracts Open Graph tags from shared URLs:
async function fetchMetadata(url: string) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const html = await res.text();
const title = html.match(/<meta property="og:title" content="([^"]+)"/)?.at(1) ||
html.match(/<title>([^<]+)<\/title>/)?.at(1) || '';
const description = html.match(/<meta property="og:description" content="([^"]+)"/)?.at(1) || '';
const image = html.match(/<meta property="og:image" content="([^"]+)"/)?.at(1) || '';
return { title, description, image };
} catch {
return { title: '', description: '', image: '' };
}
}
What does fetchMetadata do?
When someone shares a URL, this function fetches the page HTML and pulls out the Open Graph title, description, and thumbnail image. These tags are the standard way websites provide link previews. The 5-second timeout prevents the function from hanging on slow or unreachable pages.
- Add the detectPlatform helper and the callGemini function next:
function detectPlatform(url: string): string {
if (/youtube\.com|youtu\.be/.test(url)) return 'youtube';
if (/instagram\.com/.test(url)) return 'instagram';
if (/tiktok\.com/.test(url)) return 'tiktok';
if (/twitter\.com|x\.com/.test(url)) return 'twitter';
return 'web';
}
async function callGemini(prompt: string): Promise<any> {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { response_mime_type: 'application/json' },
}),
}
);
const data = await res.json();
return JSON.parse(data.candidates[0].content.parts[0].text);
}
What do these functions do?
- detectPlatform uses regex patterns to identify which platform a URL came from (YouTube, Instagram, TikTok, or Twitter/X). This helps the browse UI display relevant icons later.
- callGemini sends a prompt to the Gemini REST API. The response_mime_type: 'application/json' setting forces Gemini to return structured JSON instead of freeform text, making parsing reliable.
- Finally, add the main request handler that ties everything together:
serve(async (req) => {
const { content_type, payload } = await req.json();
let meta = { title: '', description: '', image: '' };
let source_platform = 'unknown';
if (content_type === 'link') {
meta = await fetchMetadata(payload);
source_platform = detectPlatform(payload);
}
const prompt = `You are a content categorization AI. Given the following content, return a JSON object with: category (one of: Technology, News, Shopping, Food, Travel, Entertainment, Education, Finance, Health, Social, Reference, Other), tags (array of up to 5 strings), summary (one-line string), source_platform (string).
Content type: ${content_type}
Payload: ${payload}
Title: ${meta.title}
Description: ${meta.description}`;
let aiResult;
try {
aiResult = await callGemini(prompt);
} catch {
aiResult = { category: 'Other', tags: [], summary: 'Could not process content.', source_platform };
}
const { error } = await supabase.from('saved_items').insert({
content_type,
original_content: payload,
title: meta.title || payload.slice(0, 100),
description: meta.description,
category: aiResult.category || 'Other',
tags: aiResult.tags || [],
thumbnail_url: meta.image,
ai_summary: aiResult.summary || '',
source_platform: aiResult.source_platform || source_platform,
});
if (error) return new Response(JSON.stringify({ error }), { status: 500 });
return new Response(JSON.stringify({ success: true }), { status: 200 });
});
What does the main handler do?
- It reads the content_type and payload from the request body (sent by the mobile app later).
- For links, it fetches Open Graph metadata and detects the source platform before sending everything to Gemini.
- The try/catch around callGemini ensures the function always saves something to the database, even if the AI is temporarily unavailable.
- The final insert writes the enriched record into saved_items with all AI-generated fields populated.
- Save supabase/functions/process-item/index.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on to deployment.
ⓧ I'd like to double check the full code
Here is the complete index.ts file for your Edge Function:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY')!;
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
async function fetchMetadata(url: string) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const html = await res.text();
const title = html.match(/<meta property="og:title" content="([^"]+)"/)?.at(1) ||
html.match(/<title>([^<]+)<\/title>/)?.at(1) || '';
const description = html.match(/<meta property="og:description" content="([^"]+)"/)?.at(1) || '';
const image = html.match(/<meta property="og:image" content="([^"]+)"/)?.at(1) || '';
return { title, description, image };
} catch {
return { title: '', description: '', image: '' };
}
}
function detectPlatform(url: string): string {
if (/youtube\.com|youtu\.be/.test(url)) return 'youtube';
if (/instagram\.com/.test(url)) return 'instagram';
if (/tiktok\.com/.test(url)) return 'tiktok';
if (/twitter\.com|x\.com/.test(url)) return 'twitter';
return 'web';
}
async function callGemini(prompt: string): Promise<any> {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { response_mime_type: 'application/json' },
}),
}
);
const data = await res.json();
return JSON.parse(data.candidates[0].content.parts[0].text);
}
serve(async (req) => {
const { content_type, payload } = await req.json();
let meta = { title: '', description: '', image: '' };
let source_platform = 'unknown';
if (content_type === 'link') {
meta = await fetchMetadata(payload);
source_platform = detectPlatform(payload);
}
const prompt = `You are a content categorization AI. Given the following content, return a JSON object with: category (one of: Technology, News, Shopping, Food, Travel, Entertainment, Education, Finance, Health, Social, Reference, Other), tags (array of up to 5 strings), summary (one-line string), source_platform (string).\n\nContent type: ${content_type}\nPayload: ${payload}\nTitle: ${meta.title}\nDescription: ${meta.description}`;
let aiResult;
try {
aiResult = await callGemini(prompt);
} catch {
aiResult = { category: 'Other', tags: [], summary: 'Could not process content.', source_platform };
}
const { error } = await supabase.from('saved_items').insert({
content_type,
original_content: payload,
title: meta.title || payload.slice(0, 100),
description: meta.description,
category: aiResult.category || 'Other',
tags: aiResult.tags || [],
thumbnail_url: meta.image,
ai_summary: aiResult.summary || '',
source_platform: aiResult.source_platform || source_platform,
});
if (error) return new Response(JSON.stringify({ error }), { status: 500 });
return new Response(JSON.stringify({ success: true }), { status: 200 });
});
Deploy the function and test the pipeline
With the code written, you need to deploy it to Supabase's edge network and provide your Gemini API key as a secret.
- Link your local project to your remote Supabase project by running the following command (replace the project ID with yours from the Supabase dashboard URL):
supabase link --project-ref [[SUPABASE_PROJECT_ID="your-project-id-from-dashboard-url"]]
Where do I find my project ID?
Your project ID is the string in your Supabase dashboard URL after /project/. For example, if your URL is https://supabase.com/dashboard/project/abcdefghijk, the project ID is abcdefghijk.
- The CLI may ask for your database password. You can leave it blank and press Enter to skip.
- Deploy the Edge Function by running:
supabase functions deploy process-item
What does this command do?
The CLI bundles your function code and its dependencies, uploads it to Supabase, and generates a global URL where the function can be invoked. After deployment, the function is live and accessible at your project's functions endpoint.
- Store your Gemini API key as a secret so the function can access it at runtime:
supabase secrets set GEMINI_API_KEY=your-key-here
Why secrets instead of hardcoding?
Secrets are encrypted environment variables that Supabase injects into your function at runtime. They are never exposed in source code or build logs. You do not need to redeploy after setting a secret.
Deploy failed?
Make sure you have linked your local project to your remote Supabase project. If you haven't done this yet, run supabase link --project-ref followed by your project reference ID (found in the Supabase dashboard URL after /project/).
help me fix my Edge Function deployment
- Test the deployed function by sending a sample link via curl. Replace the project URL and anon key with the values you noted during setup:
curl --request POST 'https://[[SUPABASE_URL="your-project-ref"]].supabase.co/functions/v1/process-item' \
--header 'Authorization: Bearer [[SUPABASE_ANON_KEY="your-anon-key"]]' \
--header 'Content-Type: application/json' \
--data '{"content_type": "link", "payload": "https://github.com"}'
You should see a JSON response with {"success":true} confirming the full pipeline worked.
- Verify the record was saved by returning to the Supabase dashboard, opening Table Editor, and clicking on saved_items. You should see a new row with an AI-generated category, tags, and summary.
Seeing an error response?
- If you get a 401 error, double-check that your anon key is correct. Find it in the Supabase dashboard under Settings > API.
- If you get a 500 error, the function ran but something went wrong inside it. Check the Edge Function Logs in the Supabase dashboard for the full error message.
- If the Gemini call fails, make sure your API key is valid and that the free tier is available in your region.
help me debug my Edge Function error
Your backend is live. The database stores content with AI-enriched metadata, and the Edge Function handles the full pipeline from URL fetching to Gemini categorization to database insertion. Next up, you'll wire the mobile app's share intent to call this function automatically.
Integrate Share Intents
Your backend is ready to receive and process content. Now it is time to connect the mobile app to it.
When someone shares a link, image, or text snippet from any other app on their phone, Android fires a share intent. Your app needs to intercept that intent, figure out what type of content it is, and send it to the Edge Function for AI categorization.
In this step, get ready to:
- Implement the share intent hook in your app's root layout.
- Wire shared content to the Supabase Edge Function.
- Test the full share flow on your Android device.
Set up the share intent hook in the root layout
With Expo Router, the root layout file is the entry point for your entire app. The expo-share-intent library uses a deeplink URL under the hood to pass shared data into your app. This means the hook must live in the layout so it can intercept the deeplink before any screen renders.
- In VS Code, open the file app/_layout.tsx.
- Replace its entire contents with the following code:
import { useEffect } from 'react';
import { View, Text, ActivityIndicator, Alert } from 'react-native';
import { useShareIntent } from 'expo-share-intent';
import { createClient } from '@supabase/supabase-js';
import { Stack } from 'expo-router';
// Replace these with your Supabase project values
const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-key';
const EDGE_FUNCTION_URL = `${SUPABASE_URL}/functions/v1/process-item`;
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
export default function RootLayout() {
// Hook into the Android share intent system
const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntent();
// Show a loading screen while the shared content is being processed
if (hasShareIntent) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text>Processing shared content...</Text>
</View>
);
}
return <Stack />;
}
What does this code do?
The useShareIntent() hook listens for incoming share data via a deeplink URL that Android sends when you share to the app. It returns four values: hasShareIntent (boolean flag), shareIntent (the shared data object), resetShareIntent (cleanup function), and error.
When hasShareIntent is true, the component renders a loading spinner instead of the normal app. This gives visual feedback while the content is processed in the background.
- Replace 'https://your-project.supabase.co' with your actual Supabase project URL from the dashboard.
- Replace 'your-anon-key' with your actual Supabase anon key (found under **Settings** then **API** in the Supabase dashboard).
Where to find your Supabase credentials
In the Supabase dashboard, click **Settings** in the left sidebar, then **API**. Your project URL is at the top, and the anon key is listed under **Project API keys**.
- Save app/_layout.tsx.
Seeing import errors in VS Code?
Make sure you installed expo-share-intent and @supabase/supabase-js in Step 1. If the imports show red squiggles, try restarting the TypeScript server. Help me fix import errors in my layout file.
Wire shared content to the Edge Function
The loading screen is in place, but nothing actually happens with the shared data yet. You need a useEffect that fires when hasShareIntent becomes true, determines the content type, and POSTs it to your Edge Function.
- In app/_layout.tsx, find this line inside the RootLayout function:
const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntent();
- Directly below it, add the following useEffect block:
useEffect(() => {
if (!hasShareIntent || !shareIntent) return;
const processSharedContent = async () => {
// Determine content type from the share intent data
let content_type: string;
let payload: string;
if (shareIntent.webUrl) {
content_type = 'link';
payload = shareIntent.webUrl;
} else if (shareIntent.files && shareIntent.files.length > 0) {
content_type = 'image';
payload = shareIntent.files[0].path;
} else {
content_type = 'text';
payload = shareIntent.text || '';
}
// Send to Edge Function for AI processing
try {
const res = await fetch(EDGE_FUNCTION_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SUPABASE_ANON_KEY}` },
body: JSON.stringify({ content_type, payload }),
});
if (res.ok) {
Alert.alert('Saved!', 'Content added to your vault.');
} else {
Alert.alert('Error', 'Failed to save content.');
}
} catch (e) {
Alert.alert('Error', 'Network error.');
} finally {
resetShareIntent();
}
};
processSharedContent();
}, [hasShareIntent, shareIntent]);
What does this code do?
The useEffect watches hasShareIntent and shareIntent for changes. When both are truthy, it runs processSharedContent.
The function checks shareIntent.webUrl first (links shared from browsers), then shareIntent.files (images from gallery or screenshots), and falls back to shareIntent.text (plain text snippets).
It sends the content to your Edge Function with an Authorization header (required by Supabase). On success, the user sees an alert confirming the save. On failure, an error alert appears. Either way, resetShareIntent() clears the shared data so the app returns to its normal state.
- Save app/_layout.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/_layout.tsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/_layout.tsx file:
import { useEffect } from 'react';
import { View, Text, ActivityIndicator, Alert } from 'react-native';
import { useShareIntent } from 'expo-share-intent';
import { createClient } from '@supabase/supabase-js';
import { Stack } from 'expo-router';
const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-key';
const EDGE_FUNCTION_URL = `${SUPABASE_URL}/functions/v1/process-item`;
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
export default function RootLayout() {
const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntent();
useEffect(() => {
if (!hasShareIntent || !shareIntent) return;
const processSharedContent = async () => {
let content_type: string;
let payload: string;
if (shareIntent.webUrl) {
content_type = 'link';
payload = shareIntent.webUrl;
} else if (shareIntent.files && shareIntent.files.length > 0) {
content_type = 'image';
payload = shareIntent.files[0].path;
} else {
content_type = 'text';
payload = shareIntent.text || '';
}
try {
const res = await fetch(EDGE_FUNCTION_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SUPABASE_ANON_KEY}` },
body: JSON.stringify({ content_type, payload }),
});
if (res.ok) {
Alert.alert('Saved!', 'Content added to your vault.');
} else {
Alert.alert('Error', 'Failed to save content.');
}
} catch (e) {
Alert.alert('Error', 'Network error.');
} finally {
resetShareIntent();
}
};
processSharedContent();
}, [hasShareIntent, shareIntent]);
if (hasShareIntent) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text>Processing shared content...</Text>
</View>
);
}
return <Stack />;
}
Test the share flow on your Android device
The code is complete. Time to rebuild and test the end-to-end flow on your physical device.
- Start the Expo development server by running:
npx expo start --dev-client
- On your Android device, open the development build of your app. It should connect to the dev server.
- Open Chrome on your Android device.
- Navigate to any webpage (for example, a news article or blog post).
- Tap the share button in Chrome (the three-dot menu, then **Share**).
- Select your app (AIContentVault) from the share sheet.
You should see the "Processing shared content..." loading indicator briefly, followed by a "Saved! Content added to your vault." alert.
- Return to the Supabase dashboard and open the **Table Editor** for the saved_items table.
- Verify that a new row appeared with AI-generated values in the category, tags, and ai_summary columns.
Not seeing your app in the share sheet?
The share intent configuration lives in app.json and requires a fresh development build to take effect. If you changed the plugin config after your last build, run a new build with eas build --platform android --profile development and install the new APK.
Also confirm the androidIntentFilters in your app.json includes "text/*" and "image/*". Help me debug why my app doesn't appear in the Android share sheet.
Seeing "Error: Failed to save content" alert?
This means the fetch to the Edge Function returned a non-200 status. Double check that your SUPABASE_URL and SUPABASE_ANON_KEY values in app/_layout.tsx match what is in your Supabase dashboard. Also verify the Edge Function is deployed by running:
supabase functions list
You should see process-item in the output. Help me debug my Edge Function connection.
Your app can now receive content from any app on your phone and automatically categorize it with AI. Next up, you will refine the AI pipeline to handle edge cases and produce better structured output.
Refine the AI Categorization Pipeline
Your Edge Function can receive shared content and ask Gemini to categorize it. However, the current prompt gives Gemini too much freedom. It might return unexpected category names, malformed tags, or miss the platform entirely.
In this step, you will lock down the AI output with structured constraints, add video URL detection for platforms like YouTube and Instagram, and build fallback logic so the pipeline never crashes on bad data.
In this step, get ready to:
- Refine the Gemini prompt to produce reliable structured JSON with a fixed set of categories.
- Add video URL detection and pass platform context to Gemini for better categorization.
- Add validation and fallback logic so the pipeline handles errors gracefully.
Define valid categories and refine the Gemini prompt
Right now, Gemini can return any string as a category. If it invents a category like "Lifestyle" or "Music" that your app doesn't expect, the filtering UI breaks silently.
- In supabase/functions/process-item/index.ts, find this line near the top of the file:
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
- Add the following constant directly below it:
const VALID_CATEGORIES = ['Technology','News','Shopping','Food','Travel','Entertainment','Education','Finance','Health','Social','Reference','Other'];
What does this constant do?
This array is the single source of truth for allowed category values. You will use it both in the prompt (so Gemini knows the options) and in validation (to reject anything unexpected).
Next, update the prompt string inside the serve handler to reference this array and give Gemini clearer instructions.
- Find the existing prompt variable assignment inside the serve handler. It starts with:
const prompt = `You are a content categorization AI. Given the following content, return a JSON object with: category (one of: Technology, News, Shopping, Food, Travel, Entertainment, Education, Finance, Health, Social, Reference, Other), tags (array of up to 5 strings), summary (one-line string), source_platform (string).
Content type: ${content_type}
Payload: ${payload}
Title: ${meta.title}
Description: ${meta.description}`;
- Replace the entire prompt assignment with this updated version:
const prompt = `You are a content categorization AI. Given the following content, return a JSON object with exactly these fields:
- category: one of [${VALID_CATEGORIES.join(', ')}]
- tags: array of up to 5 relevant string tags
- summary: one concise sentence describing the content
- source_platform: the detected platform (youtube, instagram, tiktok, twitter, web, or unknown)
Content type: ${content_type}
URL/Payload: ${payload}
Title: ${meta.title}
Description: ${meta.description}
${extraContext}`;
What changed in this prompt?
The updated prompt uses VALID_CATEGORIES.join(', ') so Gemini sees the exact allowed values at runtime. It also explicitly names each field and its expected format.
The ${extraContext} placeholder at the end will inject additional hints for video URLs. You will define it in the next substep.
Seeing a reference error for extraContext?
That is expected if you save now. You will add the extraContext variable in the next substep. Hold off on deploying until all three edits are complete.
Help me fix a reference error in my Edge Function prompt.
Add video URL detection and platform context
Social media and video URLs often have limited Open Graph metadata because platforms block scraping. Telling Gemini that a URL is a video from a specific platform helps it produce better tags and summaries even when metadata is sparse.
- In the same file, find the detectPlatform function. Add this new function directly below it:
function isVideoUrl(url: string): boolean {
return /youtube\.com|youtu\.be|instagram\.com\/reel|tiktok\.com/.test(url);
}
What does isVideoUrl check?
This regex tests whether a URL matches known video hosting patterns. YouTube, YouTube short links, Instagram Reels, and TikTok all serve video content that rarely exposes rich metadata to external scrapers.
Now wire this detection into the request handler so Gemini gets extra context for video links.
- Inside the serve handler, find these two lines:
let meta = { title: '', description: '', image: '' };
let source_platform = 'unknown';
- Add a new variable directly below them:
let extraContext = '';
- Then find the block that starts with if (content_type === 'link'). Replace its body so it looks like this:
if (content_type === 'link') {
meta = await fetchMetadata(payload);
source_platform = detectPlatform(payload);
if (isVideoUrl(payload)) {
extraContext = `This is a video URL from ${source_platform}. Tag it as video content.`;
}
}
How does extraContext help Gemini?
When a user shares an Instagram Reel or YouTube video, the metadata fetch usually returns minimal or empty results because these platforms block server-side scraping. The extraContext string tells Gemini explicitly that this is video content from a known platform, so it can infer appropriate tags like "video" and the platform name even without rich metadata.
Add fallback validation for Gemini output
Even with a well-structured prompt, Gemini might occasionally return a category not in your allowed list, or return tags as a string instead of an array. Validation after the AI call ensures your database always gets clean data.
- Find the try block that calls callGemini. It currently looks like this:
try {
aiResult = await callGemini(prompt);
} catch {
aiResult = { category: 'Other', tags: [], summary: 'Could not process content.', source_platform };
}
- Replace it with this version that adds validation after a successful Gemini response:
let aiResult;
try {
aiResult = await callGemini(prompt);
// Validate category against allowed list
if (!VALID_CATEGORIES.includes(aiResult.category)) aiResult.category = 'Other';
// Ensure tags is always an array
if (!Array.isArray(aiResult.tags)) aiResult.tags = [];
} catch {
aiResult = { category: 'Other', tags: [], summary: 'Could not process content.', source_platform };
}
What do these checks do?
- VALID_CATEGORIES.includes(aiResult.category) rejects any invented category and falls back to "Other".
- Array.isArray(aiResult.tags) catches cases where Gemini returns tags as a comma-separated string instead of a proper array.
- The catch block handles network failures or malformed JSON from the AI. Your pipeline inserts a safe fallback record instead of crashing.
- Also update the supabase.from('saved_items').insert call. Find these two lines inside the insert object:
category: aiResult.category || 'Other',
tags: aiResult.tags || [],
- Replace them with the simplified version (validation already guarantees clean values):
category: aiResult.category,
tags: aiResult.tags,
- Save supabase/functions/process-item/index.ts.
- Deploy the updated function by running:
supabase functions deploy process-item
- Test the pipeline with a video URL by running:
curl -X POST https://your-project.supabase.co/functions/v1/process-item \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-anon-key" \
-d '{"content_type": "link", "payload": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
You should see {"success":true} in your terminal output.
- Open your Supabase dashboard and check the saved_items table. The new row should have a category from the allowed list, an array of tags including "video", and source_platform set to youtube.
Getting a 500 error or empty category?
- Check that VALID_CATEGORIES is defined outside the serve handler, near the top of the file after the supabase client initialization.
- Make sure extraContext is declared with let extraContext = ''; before the if (content_type === 'link') block. If it is declared inside the if block, the prompt cannot access it.
- Verify your GEMINI_API_KEY secret is still set. You can re-set it with supabase secrets set GEMINI_API_KEY=your-key-here.
Help me debug my Edge Function deployment or Gemini API errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete supabase/functions/process-item/index.ts after all the changes in this step:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY')!;
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
const VALID_CATEGORIES = ['Technology','News','Shopping','Food','Travel','Entertainment','Education','Finance','Health','Social','Reference','Other'];
async function fetchMetadata(url: string) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const html = await res.text();
const title = html.match(/<meta property="og:title" content="([^"]+)"/)?.at(1) ||
html.match(/<title>([^<]+)<\/title>/)?.at(1) || '';
const description = html.match(/<meta property="og:description" content="([^"]+)"/)?.at(1) || '';
const image = html.match(/<meta property="og:image" content="([^"]+)"/)?.at(1) || '';
return { title, description, image };
} catch {
return { title: '', description: '', image: '' };
}
}
function detectPlatform(url: string): string {
if (/youtube\.com|youtu\.be/.test(url)) return 'youtube';
if (/instagram\.com/.test(url)) return 'instagram';
if (/tiktok\.com/.test(url)) return 'tiktok';
if (/twitter\.com|x\.com/.test(url)) return 'twitter';
return 'web';
}
function isVideoUrl(url: string): boolean {
return /youtube\.com|youtu\.be|instagram\.com\/reel|tiktok\.com/.test(url);
}
async function callGemini(prompt: string): Promise<any> {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { response_mime_type: 'application/json' },
}),
}
);
const data = await res.json();
return JSON.parse(data.candidates[0].content.parts[0].text);
}
serve(async (req) => {
const { content_type, payload } = await req.json();
let meta = { title: '', description: '', image: '' };
let source_platform = 'unknown';
let extraContext = '';
if (content_type === 'link') {
meta = await fetchMetadata(payload);
source_platform = detectPlatform(payload);
if (isVideoUrl(payload)) {
extraContext = `This is a video URL from ${source_platform}. Tag it as video content.`;
}
}
const prompt = `You are a content categorization AI. Given the following content, return a JSON object with exactly these fields:
- category: one of [${VALID_CATEGORIES.join(', ')}]
- tags: array of up to 5 relevant string tags
- summary: one concise sentence describing the content
- source_platform: the detected platform (youtube, instagram, tiktok, twitter, web, or unknown)
Content type: ${content_type}
URL/Payload: ${payload}
Title: ${meta.title}
Description: ${meta.description}
${extraContext}`;
let aiResult;
try {
aiResult = await callGemini(prompt);
if (!VALID_CATEGORIES.includes(aiResult.category)) aiResult.category = 'Other';
if (!Array.isArray(aiResult.tags)) aiResult.tags = [];
} catch {
aiResult = { category: 'Other', tags: [], summary: 'Could not process content.', source_platform };
}
const { error } = await supabase.from('saved_items').insert({
content_type,
original_content: payload,
title: meta.title || payload.slice(0, 100),
description: meta.description,
category: aiResult.category,
tags: aiResult.tags,
thumbnail_url: meta.image,
ai_summary: aiResult.summary || '',
source_platform: aiResult.source_platform || source_platform,
});
if (error) return new Response(JSON.stringify({ error }), { status: 500 });
return new Response(JSON.stringify({ success: true }), { status: 200 });
});
Your AI pipeline now produces consistent, validated output for every piece of shared content. Next up, you will build the browse UI that displays all your saved items organized by these AI-generated categories and tags.
Build the Browse UI
Your backend is working. Content gets shared, processed by AI, and stored in your database with categories and tags.
Now you need a way to actually browse everything you've saved. In this step, you'll build a feed screen that pulls items from Supabase and displays them as cards with filtering, search, and pull-to-refresh.
In this step, get ready to:
- Build a feed screen that fetches and displays saved items as cards.
- Add category and tag filtering to narrow the feed.
- Add a search bar and pull-to-refresh for quick access.
Create the feed screen data layer
The feed screen lives at app/index.tsx. This is the default route in Expo Router, so it renders as your app's home screen.
- Create a new file called app/index.tsx in your project.
- Add the imports, Supabase client, category list, and the data-fetching logic by pasting this code:
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, TextInput, TouchableOpacity, Image, ScrollView, RefreshControl, StyleSheet } from 'react-native';
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-key';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// All possible categories the AI assigns to content
const CATEGORIES = ['All','Technology','News','Shopping','Food','Travel','Entertainment','Education','Finance','Health','Social','Reference','Other'];
export default function FeedScreen() {
const [items, setItems] = useState<any[]>([]);
const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [refreshing, setRefreshing] = useState(false);
// Fetch items from Supabase with optional category, tag, and search filters
const fetchItems = useCallback(async () => {
let query = supabase.from('saved_items').select('*').order('created_at', { ascending: false });
if (selectedCategory !== 'All') query = query.eq('category', selectedCategory);
if (selectedTag) query = query.contains('tags', [selectedTag]);
if (searchQuery) query = query.or(`title.ilike.%${searchQuery}%,ai_summary.ilike.%${searchQuery}%`);
const { data } = await query;
setItems(data || []);
}, [selectedCategory, selectedTag, searchQuery]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// Pull-to-refresh handler
const onRefresh = async () => {
setRefreshing(true);
await fetchItems();
setRefreshing(false);
};
What does this code do?
This sets up the data layer for your feed screen. The fetchItems function builds a Supabase query dynamically based on the current filter state.
- It starts by selecting all rows from saved_items ordered by newest first.
- If a category is selected (not 'All'), it adds an .eq() filter.
- If a tag is active, it uses .contains() to match against the tags array column.
- If the user typed a search term, it uses .or() with ilike for case-insensitive matching on both title and summary.
Build the item cards and feed layout
Each saved item renders as a card showing its thumbnail, category badge, platform indicator, title, summary, and tag chips.
- Below the onRefresh function in app/index.tsx, add the renderItem function and the component's return statement:
// Render each saved item as a card
const renderItem = ({ item }: { item: any }) => (
<View style={styles.card}>
{item.thumbnail_url ? <Image source={{ uri: item.thumbnail_url }} style={styles.thumbnail} /> : null}
<View style={styles.cardContent}>
<View style={styles.row}>
<Text style={styles.categoryBadge}>{item.category}</Text>
<Text style={styles.platform}>{item.source_platform}</Text>
</View>
<Text style={styles.title} numberOfLines={2}>{item.title || item.original_content}</Text>
<Text style={styles.summary} numberOfLines={2}>{item.ai_summary}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{(item.tags || []).map((tag: string) => (
<TouchableOpacity key={tag} onPress={() => setSelectedTag(tag === selectedTag ? null : tag)}>
<Text style={[styles.tag, selectedTag === tag && styles.tagActive]}>#{tag}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
</View>
);
return (
<View style={styles.container}>
<TextInput
style={styles.searchBar}
placeholder="Search your vault..."
value={searchQuery}
onChangeText={setSearchQuery}
/>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.categoryRow}>
{CATEGORIES.map(cat => (
<TouchableOpacity key={cat} onPress={() => { setSelectedCategory(cat); setSelectedTag(null); }}>
<Text style={[styles.categoryBtn, selectedCategory === cat && styles.categoryBtnActive]}>{cat}</Text>
</TouchableOpacity>
))}
</ScrollView>
<FlatList
data={items}
keyExtractor={item => item.id}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
/>
</View>
);
}
What's happening here?
The renderItem function defines the card layout for each saved item. It conditionally shows a thumbnail, displays the category as a badge and the platform name, truncates the title and summary to 2 lines, and renders tags as tappable chips.
Tapping a tag chip toggles selectedTag state. If the same tag is tapped again, it clears the filter. The category row at the top works the same way. Selecting a category also clears the active tag to avoid conflicting filters.
The FlatList uses a RefreshControl so the user can pull down to reload. The search bar at the very top updates searchQuery on every keystroke, which triggers fetchItems via the useEffect dependency array.
Style the browse interface
The component references a styles object that defines the visual appearance of every element.
- Below the closing } of FeedScreen, add the StyleSheet at the bottom of app/index.tsx:
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
searchBar: { margin: 12, padding: 10, backgroundColor: '#fff', borderRadius: 8, borderWidth: 1, borderColor: '#ddd' },
categoryRow: { paddingHorizontal: 12, marginBottom: 8 },
categoryBtn: { marginRight: 8, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#e0e0e0', borderRadius: 16, color: '#333' },
categoryBtnActive: { backgroundColor: '#6200ee', color: '#fff' },
card: { backgroundColor: '#fff', marginHorizontal: 12, marginBottom: 12, borderRadius: 12, overflow: 'hidden', elevation: 2 },
thumbnail: { width: '100%', height: 160, resizeMode: 'cover' },
cardContent: { padding: 12 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
categoryBadge: { backgroundColor: '#e8f5e9', color: '#2e7d32', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8, fontSize: 12 },
platform: { color: '#999', fontSize: 12 },
title: { fontWeight: 'bold', fontSize: 15, marginBottom: 4 },
summary: { color: '#555', fontSize: 13, marginBottom: 8 },
tag: { marginRight: 6, color: '#6200ee', fontSize: 12 },
tagActive: { fontWeight: 'bold', textDecorationLine: 'underline' },
});
What do these styles do?
- Cards get a white background with rounded corners and a subtle shadow (elevation: 2).
- Category buttons are pill-shaped with borderRadius: 16. The active one turns purple.
- Tags are small purple text. The active tag gets bold + underline so you can see which filter is applied.
- The thumbnail covers the full card width at 160px height, with overflow: 'hidden' on the card keeping rounded corners intact.
- Save app/index.tsx.
- Make sure your Supabase URL and anon key in this file match the ones in app/_layout.tsx.
- Run your development server and open the app on your Android device. You should see a feed of all the items you previously shared, displayed as cards with category badges, tags, and summaries.
Seeing an empty feed?
- Check that your SUPABASE_URL and SUPABASE_ANON_KEY values are correct and match your Supabase dashboard.
- Open the Supabase dashboard Table Editor and confirm that your saved_items table has rows. If it's empty, share a link from Chrome to your app first.
- Make sure your Supabase project is not paused. Free projects pause after 1 week of inactivity.
Why is my feed not loading any items?
✔️ Awesome, I've got everything!
Great. Double check you've saved app/index.tsx and verify that the feed loads your saved items.
ⓧ I'd like to double check the full code
import { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, TextInput, TouchableOpacity, Image, ScrollView, RefreshControl, StyleSheet } from 'react-native';
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-key';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const CATEGORIES = ['All','Technology','News','Shopping','Food','Travel','Entertainment','Education','Finance','Health','Social','Reference','Other'];
export default function FeedScreen() {
const [items, setItems] = useState<any[]>([]);
const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [refreshing, setRefreshing] = useState(false);
const fetchItems = useCallback(async () => {
let query = supabase.from('saved_items').select('*').order('created_at', { ascending: false });
if (selectedCategory !== 'All') query = query.eq('category', selectedCategory);
if (selectedTag) query = query.contains('tags', [selectedTag]);
if (searchQuery) query = query.or(`title.ilike.%${searchQuery}%,ai_summary.ilike.%${searchQuery}%`);
const { data } = await query;
setItems(data || []);
}, [selectedCategory, selectedTag, searchQuery]);
useEffect(() => { fetchItems(); }, [fetchItems]);
const onRefresh = async () => {
setRefreshing(true);
await fetchItems();
setRefreshing(false);
};
const renderItem = ({ item }: { item: any }) => (
<View style={styles.card}>
{item.thumbnail_url ? <Image source={{ uri: item.thumbnail_url }} style={styles.thumbnail} /> : null}
<View style={styles.cardContent}>
<View style={styles.row}>
<Text style={styles.categoryBadge}>{item.category}</Text>
<Text style={styles.platform}>{item.source_platform}</Text>
</View>
<Text style={styles.title} numberOfLines={2}>{item.title || item.original_content}</Text>
<Text style={styles.summary} numberOfLines={2}>{item.ai_summary}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{(item.tags || []).map((tag: string) => (
<TouchableOpacity key={tag} onPress={() => setSelectedTag(tag === selectedTag ? null : tag)}>
<Text style={[styles.tag, selectedTag === tag && styles.tagActive]}>#{tag}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
</View>
);
return (
<View style={styles.container}>
<TextInput
style={styles.searchBar}
placeholder="Search your vault..."
value={searchQuery}
onChangeText={setSearchQuery}
/>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.categoryRow}>
{CATEGORIES.map(cat => (
<TouchableOpacity key={cat} onPress={() => { setSelectedCategory(cat); setSelectedTag(null); }}>
<Text style={[styles.categoryBtn, selectedCategory === cat && styles.categoryBtnActive]}>{cat}</Text>
</TouchableOpacity>
))}
</ScrollView>
<FlatList
data={items}
keyExtractor={item => item.id}
renderItem={renderItem}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
searchBar: { margin: 12, padding: 10, backgroundColor: '#fff', borderRadius: 8, borderWidth: 1, borderColor: '#ddd' },
categoryRow: { paddingHorizontal: 12, marginBottom: 8 },
categoryBtn: { marginRight: 8, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#e0e0e0', borderRadius: 16, color: '#333' },
categoryBtnActive: { backgroundColor: '#6200ee', color: '#fff' },
card: { backgroundColor: '#fff', marginHorizontal: 12, marginBottom: 12, borderRadius: 12, overflow: 'hidden', elevation: 2 },
thumbnail: { width: '100%', height: 160, resizeMode: 'cover' },
cardContent: { padding: 12 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
categoryBadge: { backgroundColor: '#e8f5e9', color: '#2e7d32', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8, fontSize: 12 },
platform: { color: '#999', fontSize: 12 },
title: { fontWeight: 'bold', fontSize: 15, marginBottom: 4 },
summary: { color: '#555', fontSize: 13, marginBottom: 8 },
tag: { marginRight: 6, color: '#6200ee', fontSize: 12 },
tagActive: { fontWeight: 'bold', textDecorationLine: 'underline' },
});
Your content vault now has a fully interactive browse interface. You can filter by category, tap tags to narrow results, search by title or summary, and pull down to refresh. Next up, the Secret Mission takes your app further with image intelligence.
Secret mission
Image Intelligence with Gemini Vision
Right now, your app stores shared images with a generic title and no real analysis. What if you could make the AI actually look at the image and understand what it contains? Add Gemini's multimodal vision capabilities to analyze any shared image, describe its contents, extract visible text, and intelligently categorize it based on what the AI sees.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Supabase's free tier and the Gemini free tier, so there are no ongoing costs while your project is active.
Resources you used:
- Supabase project (free tier): your PostgreSQL database and the process-item Edge Function running in the cloud.
- EAS development build: the APK installed on your Android device.
- Local project files: the Expo project folder on your Desktop.
- Global npm package: eas-cli installed globally on your machine.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep sharing content to your vault.
- Your Supabase project stays active as long as you interact with it at least once a week.
- The app on your Android device continues working normally.
- Your local project files remain on your Desktop for future development.
- The eas-cli package remains available for future Expo builds.
Pause - I'll come back to this later
Let your cloud resources auto-pause to free up your free tier slot while keeping everything recoverable.
- Your Supabase project will automatically pause after 1 week of inactivity. You can unpause it anytime from the Supabase dashboard.
- Leave the APK installed on your device. It will not work while Supabase is paused, but will resume once you unpause the project.
- Keep your local project files as they are. No changes needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This action is permanent and cannot be undone.
- Delete your Supabase project: open the Supabase dashboard, select your project, go to Project Settings, scroll to the General tab, then scroll down to the Delete project section and click Delete project. Confirm when prompted.
- Uninstall the APK from your Android device: go to Settings on your phone, open Apps, find AIContentVault, and tap Uninstall.
- Remove the global eas-cli package by running:
npm uninstall -g eas-cli
- Delete your local project folder by running:
cd ~/Desktop
rm -rf ai-content-vault
What about my Expo and Supabase accounts?
Your Expo (EAS) account and Supabase account are free and can be reused for future projects. There is no need to delete them unless you are sure you will not use them again.
That's a Wrap!
That's a Wrap!
Nice work! You just built a full-stack mobile content vault that uses AI to automatically organize anything you share to it from your phone.
You've learned how to:
- Build a React Native app with Expo SDK 56, Android share intent support, and a cloud-based development build via EAS.
- Design a PostgreSQL schema and create a Supabase Edge Function that calls Google Gemini for intelligent categorization of links, text, social media URLs, and video content.
- Build a browsable, filterable feed UI organized by AI-generated categories with tag filtering and text search.
- Secret Mission: Added multimodal AI vision analysis to intelligently categorize any shared image based on visual content.
Ready to quiz yourself?