Build a Social Feed App with Supabase
Build a real-time social feed mobile app with React Native and Supabase.
Introduction
30 Second Summary
Every social media app you use has the same core pattern: users sign in, post content, and see a live feed of what others are sharing. That pattern powers everything from Twitter to Threads.
In this project, you will build your own social feed app called "BuzzFeed" that runs on your physical iPhone. You'll set up user accounts, a database for posts, and real-time updates so new posts appear instantly.
What You'll Build
A React Native mobile app with user authentication, a posts database, and a real-time feed, all running on your iPhone via Expo Go and powered by a Supabase cloud backend.
By the end of this project, you'll have:
- A React Native app built with Expo that runs live on your physical iPhone.
- A complete authentication system (sign up, sign in, sign out) powered by Supabase.
- A real-time post feed where users can create posts and see others' posts appear instantly.
- Secret Mission: Add a like system so users can heart posts and see like counts.
Are there any prerequisites?
No prior experience with React Native or Supabase is needed. This guide walks you through setting up every tool from scratch, including Node.js, Expo, and your Supabase account.
Before We Start
Before We Start...
Set Up Your Development Environment
Every mobile app starts with a development environment that translates your code into something a phone can run. React Native with Expo handles this translation for you, letting you build iOS apps without needing a Mac or Xcode.
In this step, you'll install Node.js (the engine that powers your development tools), create your app project with Expo, and connect it live to your iPhone.
In this step, get ready to:
- Install Node.js on your computer.
- Create and run an Expo project called BuzzFeed.
- See the app running live on your iPhone via Expo Go.
Install Node.js
Node.js is a JavaScript runtime that powers all the development tools you'll use in this project. Expo's build system, package manager, and development server all depend on it.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
- Check if Node.js is already installed by running this command:
node --version
✔️ I see version 24 or higher
You're all set. Your output should look something like v24.18.0 or any v24.x.x number.
- Confirm npm is also available by running:
npm --version
You should see a version number printed. Move on to the next substep.
ⓧ I see an older version
You have Node.js installed, but it's an older version that may not be compatible with this project's dependencies.
- Open https://nodejs.org/en/download in your browser.
- Download and run the v24 LTS installer for your platform (Windows 64-bit .msi or macOS .pkg).
- Accept the default settings in the installer wizard and complete the installation.
- Close your current terminal and open a new one in VS Code (Ctrl+`) so the updated PATH takes effect.
- Verify the update by running:
node --version
You should now see v24.x.x in the output.
ⓧ Command not found
Node.js is not installed on your machine yet. Let's install it now.
- Open https://nodejs.org/en/download in your browser.
- Download the v24 LTS installer for your platform (Windows 64-bit .msi or macOS .pkg).
- Run the installer and accept all default settings.
- Close your current terminal and open a new one in VS Code (Ctrl+`) so the PATH includes Node.js.
- Verify the installation by running:
node --version
You should see v24.x.x printed in the terminal.
- Also confirm npm is available by running:
npm --version
You should see a version number. npm is Node's package manager and is installed automatically alongside Node.js.
What is Node.js?
Node.js is a JavaScript runtime that lets you run JavaScript outside a web browser. Expo uses it behind the scenes to bundle your app code, run the development server, and manage packages.
Create and run the Expo project
Expo is a framework that wraps React Native and gives you tools to build, test, and deploy mobile apps without needing Xcode or Android Studio. You'll use it to create your BuzzFeed project and run it directly on your phone.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Expo project by running this command:
npx create-expo-app@latest BuzzFeed --template blank-typescript
What happens when you run this?
The npx tool may ask you to confirm installing the create-expo-app package. Type y and press Enter to proceed.
This creates a new folder called BuzzFeed on your Desktop with a minimal TypeScript Expo project inside it. The blank-typescript template gives you a clean starting point with no navigation pre-configured.
- Navigate into your new project folder and start the development server by running these commands:
cd BuzzFeed
npx expo start
A QR code appears in your terminal along with a local URL. The development server is now running and waiting for a device to connect.
Don't see a QR code?
Make sure you're inside the BuzzFeed folder when you run npx expo start. If you see an error about missing dependencies, try running npm install first, then npx expo start again.
help me debug why npx expo start isn't showing a QR code
Connect your iPhone
Expo Go is a free app that runs your project on a physical phone. It connects to the development server over WiFi, so every code change you make appears on your phone within seconds.
- On your iPhone, open the App Store and search for Expo Go.
- Download and install the Expo Go app (it's free).
- Make sure your iPhone and your computer are connected to the same WiFi network.
- Open the Camera app on your iPhone and point it at the QR code in your terminal.
- Tap the banner that appears at the top of your screen to open the app in Expo Go.
After a few seconds of loading, you should see a screen with the text "Open up App.tsx to start working on your app!" displayed in the center.
Why do both devices need the same WiFi?
The Expo development server on your computer sends your app's JavaScript bundle to your phone over the local network. If the devices are on different networks, they can't find each other.
App not loading on your iPhone?
If scanning the QR code doesn't connect, your WiFi router may be blocking device-to-device communication. Stop the server with Ctrl+C and restart it with tunnel mode:
npx expo start --tunnel
Tunnel mode routes traffic through Expo's servers instead of your local network. It's slower but works regardless of your router settings.
help me connect my iPhone to Expo Go
Your development environment is ready. Next up, you'll set up a Supabase backend with a database and authentication.
Set Up Supabase Backend
Your app is running on your iPhone, but right now it is just a placeholder screen with no data behind it. Every social feed needs a backend to store user accounts and posts.
In this step, you will set up Supabase as your app's backend. Supabase gives you a Postgres database, user authentication, and real-time data subscriptions in one platform.
In this step, get ready to:
- Create a Supabase project with a posts database table.
- Install the Supabase client library and connect it to your Expo app.
- Configure authentication settings for development.
Create a Supabase project and database table
Before your app can store posts or manage users, you need a cloud database. Supabase provides a hosted Postgres database with a web dashboard for managing your data.
- Open your browser and go to https://supabase.com.
- Click Start your project and create a free account using your email or GitHub login.
- Once signed in, click New Project.
- Enter buzzfeed as the project name.
- Choose a region close to your physical location.
- Set a database password and save it somewhere safe.
- Click Create new project.
Wait about 30 seconds for your project to finish provisioning.
What is Supabase?
Supabase is an open-source backend-as-a-service. It wraps a full Postgres database with authentication, file storage, and real-time subscriptions out of the box.
Instead of building your own server, you get a ready-made backend with a dashboard to manage everything visually.
Now you will create the database table that stores posts.
- In the left sidebar of your Supabase dashboard, click SQL Editor.
- Paste the following SQL into the editor and click Run:
-- Create the posts table to store all user posts
create table posts (
id uuid default gen_random_uuid() primary key,
content text,
user_id uuid,
author_email text,
created_at timestamptz default now()
);
-- Enable Row Level Security so only authenticated users can access posts
alter table posts enable row level security;
-- Allow any authenticated user to read all posts
create policy "Authenticated users can read all posts"
on posts for select
to authenticated
using (true);
-- Allow authenticated users to insert only their own posts
create policy "Authenticated users can insert their own posts"
on posts for insert
to authenticated
with check (user_id = auth.uid());
What does this SQL do?
- The create table statement builds a posts table with five columns: a unique ID, the post content, the author's user ID, their email, and a timestamp.
- The alter table line enables Row Level Security (RLS), which locks down the table so access must go through explicit policies.
- The first policy lets any logged-in user read all posts.
- The second policy only lets users insert posts where the user_id matches their own authenticated ID.
- You should see a Success. No rows returned message at the bottom of the editor.
- Click Table Editor in the left sidebar to verify the posts table appears in the list.
Don't see the posts table?
- Make sure you clicked Run after pasting the SQL. The editor does not auto-execute.
- Check that the bottom panel shows success, not a syntax error. A missing semicolon or typo in a column name is the most common cause.
- If you see a table already exists error, your SQL ran successfully on a prior attempt. Check the Table Editor to confirm.
Help me troubleshoot my SQL Editor issue in Supabase.
Get your API credentials and install the Supabase client
Your React Native app needs two pieces of information to talk to Supabase: a project URL and a public API key. You will store these in an environment file, then create a helper that initializes the Supabase client.
- In the Supabase dashboard, click Settings in the left sidebar.
- Click API under the Configuration section.
- Copy your Project URL (it looks like https://abc123.supabase.co).
- Copy your anon public key (a long string starting with eyJ).
Why is the anon key safe to use in client code?
The anon key is designed for client-side use. It only grants access through the Row Level Security policies you defined. Without those policies, it cannot read or write any data.
Now switch back to your terminal in VS Code. Make sure you are inside the BuzzFeed project folder.
- Install the Supabase client and its dependencies by running:
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill
What are these packages?
- @supabase/supabase-js is the official JavaScript client for talking to your Supabase project.
- @react-native-async-storage/async-storage provides persistent storage so user sessions survive app restarts.
- react-native-url-polyfill fills in a missing URL API that Supabase needs in React Native.
- Confirm the install succeeded by checking that no errors appeared in your terminal output.
Seeing install errors?
- Make sure you are inside the BuzzFeed folder, not one level above it.
- If you see a peer dependency warning, that is safe to ignore. Expo resolves compatible versions for you.
Help me fix my npm install error in my Expo project.
Next, create the environment file that holds your Supabase credentials.
- In VS Code, create a new file called .env in the root of your BuzzFeed folder.
- Paste the following into .env, replacing the placeholder values with the URL and key you copied from the Supabase dashboard:
EXPO_PUBLIC_SUPABASE_URL=your-project-url-here
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
Why the EXPO_PUBLIC_ prefix?
Expo only exposes environment variables to your app code if they start with EXPO_PUBLIC_. Without this prefix, the values would be invisible to your JavaScript at runtime.
- Save .env.
Finally, create the helper file that initializes the Supabase client.
- Create a new folder called lib inside your BuzzFeed project root.
- Inside lib, create a new file called supabase.ts.
- Paste the following code into lib/supabase.ts:
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from '@supabase/supabase-js'
// Read credentials from environment variables
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
// Create and export the Supabase client with session persistence
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})
What does this file do?
- The URL polyfill import patches a missing browser API so Supabase works in React Native.
- AsyncStorage is passed as the storage option so user sessions are saved to the device. If the user closes the app, they stay logged in.
- detectSessionInUrl: false disables URL-based session detection, which does not apply in a mobile app.
- Save lib/supabase.ts.
- Check that VS Code does not show any red squiggly lines in the file. If TypeScript recognizes the imports, you are good to go.
Seeing red underlines on the imports?
- Make sure the npx expo install command completed without errors in the previous step.
- Try closing and reopening VS Code. TypeScript sometimes needs a restart to pick up newly installed packages.
Help me fix TypeScript import errors in my Supabase helper file.
✔️ Awesome, I've got everything!
Great. Double check you have saved both .env and lib/supabase.ts.
ⓧ I'd like to double check the full code
Here is the complete lib/supabase.ts file for comparison:
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})
And the complete .env file:
EXPO_PUBLIC_SUPABASE_URL=your-project-url-here
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
Disable email confirmation for testing
By default, Supabase requires new users to confirm their email before they can sign in. For development, you will disable this so you can test instantly without checking an inbox.
- In the Supabase dashboard, click Authentication in the left sidebar.
- Click Providers in the submenu.
- Expand the Email provider section.
- Find the Confirm Email toggle and turn it off.
- Click Save.
The toggle should now show as disabled.
Why disable email confirmation?
Disabling confirmation means new accounts can sign in immediately without clicking a verification link. This is only for development convenience. In a production app, you would keep confirmation enabled to prevent fake signups.
Can't find the Confirm Email toggle?
- Make sure you are in Authentication then Providers, not the general Auth settings page.
- The toggle is inside the Email provider dropdown. Click on Email to expand it.
Help me find the email confirmation setting in Supabase.
Your backend is ready. Next up, you will build the authentication screen so users can sign up and log into your app.
Build the Authentication Screen
Your Supabase backend is ready with a posts table and security policies in place. Now it's time to build the front door of your app: the authentication screen.
In this step, you'll create a sign-in and sign-up form that talks to Supabase Auth. You'll also update App.tsx to switch between the auth screen and the main app depending on whether a user is logged in.
In this step, get ready to:
- Create an Auth component with email/password inputs and sign-in/sign-up buttons.
- Wire up App.tsx to manage authentication state and conditionally render screens.
Create the Auth component
The Auth component is a self-contained form that handles both signing in existing users and creating new accounts. It uses Supabase's built-in authentication methods to manage credentials securely.
- In VS Code, right-click in the Explorer sidebar and select New Folder...
- Name the folder components.
- Right-click the new components folder and select New File...
- Name the file Auth.tsx.
- Paste the following code into Auth.tsx:
import { useState } from 'react'
import { Alert, StyleSheet, View, TextInput, TouchableOpacity, Text } from 'react-native'
import { supabase } from '../lib/supabase'
export default function Auth() {
// Track form inputs and loading state
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
// Sign in an existing user with their email and password
async function signInWithEmail() {
setLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) Alert.alert('Error', error.message)
setLoading(false)
}
// Create a new account with email and password
async function signUpWithEmail() {
setLoading(true)
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) Alert.alert('Error', error.message)
else Alert.alert('Success', 'Account created! You are now signed in.')
setLoading(false)
}
return (
<View style={styles.container}>
{/* App title and subtitle */}
<Text style={styles.title}>BuzzFeed</Text>
<Text style={styles.subtitle}>Sign in or create an account</Text>
{/* Email input */}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
{/* Password input - hidden characters */}
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
/>
{/* Sign In button for existing users */}
<TouchableOpacity
style={styles.button}
onPress={signInWithEmail}
disabled={loading}
>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
{/* Sign Up button for new users */}
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={signUpWithEmail}
disabled={loading}
>
<Text style={[styles.buttonText, styles.secondaryButtonText]}>Sign Up</Text>
</TouchableOpacity>
</View>
)
}
// Styles for the auth form layout
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
color: '#666',
marginBottom: 32,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#4a90d9',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginBottom: 12,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#4a90d9',
},
secondaryButtonText: {
color: '#4a90d9',
},
})
What does this code do?
- signInWithEmail() calls Supabase's signInWithPassword method to authenticate an existing user.
- signUpWithEmail() calls signUp to register a new account. Since you disabled email confirmation in Step 2, the user is signed in immediately.
- Both functions set loading to true while the request is in progress, disabling the buttons to prevent double-taps.
- If anything goes wrong, the error message from Supabase is shown in a native alert popup.
- Save components/Auth.tsx.
You won't see a change on your phone yet. The next substep imports this component into App.tsx.
Seeing red squiggly lines in VS Code?
- Check that your lib/supabase.ts file exists one level up from the components folder. The import uses ../lib/supabase which means "go up one folder, then into lib".
- Make sure the file is named exactly Auth.tsx (capital A, .tsx extension).
Why is my Auth.tsx showing TypeScript errors?
Set up the main App with auth state
Right now, App.tsx still shows the default Expo welcome text. You need it to check whether a user is signed in and show the right screen accordingly.
- Open App.tsx in VS Code.
- Select all the existing code and replace it with the following:
import { useEffect, useState } from 'react'
import { View, StyleSheet } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
export default function App() {
// Track the current auth session
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
// Check if user is already signed in when app opens
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
// Listen for sign-in and sign-out events in real time
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
// Clean up the listener when component unmounts
return () => subscription.unsubscribe()
}, [])
// Show Auth screen when not signed in
if (!session) {
return <Auth />
}
// Placeholder for the feed screen (coming in the next step)
return (
<View style={styles.container}>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
})
What does this code do?
- getSession() checks for an existing session when the app first opens. If the user signed in previously, they skip the auth screen.
- onAuthStateChange() listens for auth events like sign-in and sign-out. Whenever the state changes, it updates the session variable.
- The conditional if (!session) is the routing logic. No session means show the Auth screen. A valid session means show the main app (empty for now).
- Save App.tsx.
- Start the Expo development server by running this command in your terminal:
npx expo start
- Scan the QR code in the terminal with your iPhone camera.
- You should see the BuzzFeed sign-in form on your iPhone with email and password fields plus two buttons.
- Enter a test email and password, then tap Sign Up.
- You should see a "Success" alert confirming your account was created. After dismissing it, the screen goes blank (the empty placeholder view). This means auth is working.
App not loading on your iPhone?
- Make sure your iPhone and computer are on the same WiFi network.
- If the QR code scan opens a browser instead of Expo Go, make sure you have the Expo Go app installed from the App Store.
- If the connection times out, try running npx expo start --tunnel instead. This routes through a cloud tunnel and works even when your router blocks local connections.
Why won't my Expo app connect to my iPhone?
✔️ Awesome, I've got everything!
Great. Make sure both components/Auth.tsx and App.tsx are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full components/Auth.tsx:
import { useState } from 'react'
import { Alert, StyleSheet, View, TextInput, TouchableOpacity, Text } from 'react-native'
import { supabase } from '../lib/supabase'
export default function Auth() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
async function signInWithEmail() {
setLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) Alert.alert('Error', error.message)
setLoading(false)
}
async function signUpWithEmail() {
setLoading(true)
const { error } = await supabase.auth.signUp({
email,
password,
})
if (error) Alert.alert('Error', error.message)
else Alert.alert('Success', 'Account created! You are now signed in.')
setLoading(false)
}
return (
<View style={styles.container}>
<Text style={styles.title}>BuzzFeed</Text>
<Text style={styles.subtitle}>Sign in or create an account</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
/>
<TouchableOpacity
style={styles.button}
onPress={signInWithEmail}
disabled={loading}
>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={signUpWithEmail}
disabled={loading}
>
<Text style={[styles.buttonText, styles.secondaryButtonText]}>Sign Up</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
color: '#666',
marginBottom: 32,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#4a90d9',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginBottom: 12,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#4a90d9',
},
secondaryButtonText: {
color: '#4a90d9',
},
})
And here is the full App.tsx:
import { useEffect, useState } from 'react'
import { View, StyleSheet } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
export default function App() {
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
return () => subscription.unsubscribe()
}, [])
if (!session) {
return <Auth />
}
return (
<View style={styles.container}>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
})
Your app now has a working front door. Users can create accounts and sign in. Next up, you'll build the post feed so signed-in users have something to see.
Build the Post Feed
In the previous step, you built the authentication system so users can sign up and sign in. Now it's time to build the core feature of any social app: the feed.
The feed is the main screen users see after logging in. It needs to pull posts from your Supabase database, display them in a scrollable list, and update in real time when someone creates a new post.
In this step, get ready to:
- Create a Feed component that fetches and displays posts from Supabase.
- Add a real-time subscription so new posts appear instantly without refreshing.
Create the Feed component
The Feed component is the heart of BuzzFeed. It queries your posts table, renders each post as a card, and gives users a way to sign out.
- In VS Code, create a new file called Feed.tsx inside the components folder (the same folder where Auth.tsx lives).
- Add the following code to components/Feed.tsx:
import { useEffect, useState } from 'react'
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
// Define the shape of a post object
type Post = {
id: string
content: string
user_id: string
author_email: string
created_at: string
}
export default function Feed({ session }: { session: Session }) {
const [posts, setPosts] = useState<Post[]>([])
// Fetch posts on mount
useEffect(() => {
fetchPosts()
}, [])
// Query all posts from Supabase, newest first
async function fetchPosts() {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (data) setPosts(data)
}
// Convert a timestamp into a human-readable relative time
function formatTime(dateString: string) {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString()
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>BuzzFeed</Text>
<TouchableOpacity onPress={() => supabase.auth.signOut()}>
<Text style={styles.signOut}>Sign Out</Text>
</TouchableOpacity>
</View>
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.postCard}>
<Text style={styles.author}>{item.author_email}</Text>
<Text style={styles.content}>{item.content}</Text>
<Text style={styles.time}>{formatTime(item.created_at)}</Text>
</View>
)}
ListEmptyComponent={
<Text style={styles.empty}>No posts yet. Be the first to post!</Text>
}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
paddingTop: 60,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
headerTitle: {
fontSize: 22,
fontWeight: 'bold',
},
signOut: {
color: '#4a90d9',
fontSize: 14,
},
postCard: {
backgroundColor: '#fff',
padding: 16,
marginHorizontal: 12,
marginTop: 12,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
author: {
fontWeight: '600',
fontSize: 14,
color: '#333',
},
content: {
fontSize: 16,
marginTop: 6,
color: '#111',
lineHeight: 22,
},
time: {
fontSize: 12,
color: '#999',
marginTop: 8,
},
empty: {
textAlign: 'center',
marginTop: 40,
color: '#999',
fontSize: 16,
},
})
What does this code do?
The Post type defines the shape of each post object from the database. The component accepts a session prop so it knows who is logged in.
The fetchPosts function queries all posts from Supabase and sorts them newest first. The formatTime helper converts raw timestamps into friendly labels like "5m ago" or "2h ago".
The FlatList component efficiently renders a scrollable list of posts. The header includes a Sign Out button that calls supabase.auth.signOut() to end the session.
- Save components/Feed.tsx.
Seeing TypeScript errors?
Make sure the components folder is at the top level of your BuzzFeed project, next to lib. Check that the import path '../lib/supabase' matches your folder structure.
help me fix TypeScript errors in my Feed component
Add real-time subscription
Right now, the feed only loads posts when the component first mounts. If someone else creates a post, you would need to close and reopen the app to see it.
What is Supabase Realtime?
Supabase Realtime lets your app listen for database changes as they happen. You subscribe to a channel, and whenever a new row is inserted, your app gets notified instantly without polling.
- In components/Feed.tsx, find the useEffect block that currently looks like this:
useEffect(() => {
fetchPosts()
}, [])
- Replace the entire useEffect block with the following version that adds a real-time channel subscription:
useEffect(() => {
fetchPosts()
// Subscribe to new posts in real time
const channel = supabase
.channel('posts')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts' },
(payload) => {
setPosts((current) => [payload.new as Post, ...current])
}
)
.subscribe()
// Clean up the subscription when the component unmounts
return () => {
supabase.removeChannel(channel)
}
}, [])
What does this subscription do?
The supabase.channel('posts') creates a named channel that listens for changes to the posts table. The postgres_changes event type with event: 'INSERT' means it only fires when a new row is added.
When a new post arrives, setPosts((current) => [payload.new as Post, ...current]) prepends it to the top of the list. The cleanup function calls supabase.removeChannel(channel) to stop listening when the user navigates away.
Now you need to connect the Feed component to your app so it actually appears on screen.
- In App.tsx, find this line near the top of the imports:
import Auth from './components/Auth'
- Add a new import for Feed directly below it, so both lines look like this:
import Auth from './components/Auth'
import Feed from './components/Feed'
- Next, find the empty View that renders when a session exists:
return (
<View style={styles.container}>
</View>
)
- Replace it with the following to render the Feed component and pass in the session:
return (
<View style={styles.container}>
<Feed session={session} />
</View>
)
Why pass the session to Feed?
The Feed component needs to know which user is logged in so it can display the right data. Passing session as a prop gives Feed access to the current user's ID and email.
- Save App.tsx.
- Start the Expo development server by running:
npx expo start
- Scan the QR code with your iPhone camera to open the app in Expo Go.
- Sign in with the account you created in the previous step.
You should see the Feed screen with a "BuzzFeed" header, a **Sign Out** button in the top right, and the message "No posts yet. Be the first to post!" in the center.
- Tap Sign Out to confirm it returns you to the auth screen.
- Sign back in to return to the feed.
App not loading or crashing?
Make sure your iPhone and Windows computer are on the same WiFi network. If the QR code does not connect, try running npx expo start --tunnel instead.
If you see a red error screen, check that the import path in App.tsx matches the file location: './components/Feed'.
help me debug my Expo app that crashes when loading the Feed component
✔️ Awesome, I've got everything!
Great. Double check you have saved both components/Feed.tsx and App.tsx.
ⓧ I'd like to double check the full code
Here is the complete components/Feed.tsx:
import { useEffect, useState } from 'react'
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
type Post = {
id: string
content: string
user_id: string
author_email: string
created_at: string
}
export default function Feed({ session }: { session: Session }) {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
fetchPosts()
const channel = supabase
.channel('posts')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts' },
(payload) => {
setPosts((current) => [payload.new as Post, ...current])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
async function fetchPosts() {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (data) setPosts(data)
}
function formatTime(dateString: string) {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString()
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>BuzzFeed</Text>
<TouchableOpacity onPress={() => supabase.auth.signOut()}>
<Text style={styles.signOut}>Sign Out</Text>
</TouchableOpacity>
</View>
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.postCard}>
<Text style={styles.author}>{item.author_email}</Text>
<Text style={styles.content}>{item.content}</Text>
<Text style={styles.time}>{formatTime(item.created_at)}</Text>
</View>
)}
ListEmptyComponent={
<Text style={styles.empty}>No posts yet. Be the first to post!</Text>
}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
paddingTop: 60,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
headerTitle: {
fontSize: 22,
fontWeight: 'bold',
},
signOut: {
color: '#4a90d9',
fontSize: 14,
},
postCard: {
backgroundColor: '#fff',
padding: 16,
marginHorizontal: 12,
marginTop: 12,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
author: {
fontWeight: '600',
fontSize: 14,
color: '#333',
},
content: {
fontSize: 16,
marginTop: 6,
color: '#111',
lineHeight: 22,
},
time: {
fontSize: 12,
color: '#999',
marginTop: 8,
},
empty: {
textAlign: 'center',
marginTop: 40,
color: '#999',
fontSize: 16,
},
})
And here is the complete App.tsx:
import { useEffect, useState } from 'react'
import { View, StyleSheet } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
import Feed from './components/Feed'
export default function App() {
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
return () => subscription.unsubscribe()
}, [])
if (!session) {
return <Auth />
}
return (
<View style={styles.container}>
<Feed session={session} />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
})
Your feed is live and ready to display posts. Next up, you will add post creation so users can actually write something for the feed to show.
Add Post Creation
Your feed is live and updating in real time. But right now, there are no posts to show because you have no way to create them.
In this step, you will build a post creation component that lets users type a message and submit it to Supabase. The real-time subscription you set up in the previous step will pick up new posts automatically and display them in the feed.
In this step, get ready to:
- Create a NewPost component with a text input and submit button.
- Wire NewPost into App.tsx so authenticated users can post.
- Verify real-time post delivery between your iPhone and a web browser.
Create the NewPost component
Every social app needs a way for users to submit content. You will create a compact input bar that sits at the bottom of the screen, similar to a messaging app.
- In VS Code, create a new file called NewPost.tsx inside the components folder.
- Paste the following code into components/NewPost.tsx:
import { useState } from 'react'
import { Alert, StyleSheet, TextInput, TouchableOpacity, View, Text } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
export default function NewPost({ session }: { session: Session }) {
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
async function createPost() {
// Don't allow empty posts
if (!content.trim()) {
Alert.alert('Error', 'Post cannot be empty')
return
}
setLoading(true)
// Insert the post into the posts table with user info
const { error } = await supabase.from('posts').insert({
content: content.trim(),
user_id: session.user.id,
author_email: session.user.email,
})
if (error) {
Alert.alert('Error', error.message)
} else {
// Clear the input on success
setContent('')
}
setLoading(false)
}
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="What's on your mind?"
value={content}
onChangeText={setContent}
multiline
maxLength={280}
/>
<TouchableOpacity
style={styles.button}
onPress={createPost}
disabled={loading || !content.trim()}
>
<Text style={styles.buttonText}>Post</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
padding: 12,
marginHorizontal: 12,
marginTop: 12,
borderRadius: 10,
flexDirection: 'row',
alignItems: 'center',
},
input: {
flex: 1,
fontSize: 16,
padding: 8,
maxHeight: 80,
},
button: {
backgroundColor: '#4a90d9',
borderRadius: 20,
paddingVertical: 8,
paddingHorizontal: 16,
marginLeft: 8,
},
buttonText: {
color: '#fff',
fontWeight: '600',
fontSize: 14,
},
})
What does this code do?
This component creates a horizontal input bar with a text field and a Post button.
- createPost() first checks that the input is not empty, then inserts the post into the posts table with the user's ID and email attached.
- maxLength={280} caps each post at 280 characters, just like a tweet.
- The button is disabled when loading is true or the input is empty, preventing duplicate submissions.
- Save components/NewPost.tsx.
Seeing red underlines in VS Code?
- Make sure the file is saved inside the components folder (not the project root). The import path ../lib/supabase relies on this location.
- Check that you installed @supabase/supabase-js in the earlier step. If TypeScript cannot find the module, run your install command again.
Why is my NewPost component showing TypeScript errors?
Wire everything together in App.tsx
The NewPost component exists, but the app does not render it yet. You need to import it into App.tsx and position it at the bottom of the authenticated screen.
- In App.tsx, find the existing import for Feed:
import Feed from './components/Feed'
- Add a new import for NewPost directly below it:
import NewPost from './components/NewPost'
- Next, find the authenticated return block that currently renders only the Feed:
return (
<View style={styles.container}>
<Feed session={session} />
</View>
)
- Replace that entire return block with the following, which adds NewPost in an absolute-positioned wrapper at the bottom:
return (
<View style={styles.container}>
<Feed session={session} />
<View style={styles.newPostWrapper}>
<NewPost session={session} />
</View>
</View>
)
- Finally, find the styles object at the bottom of the file. Replace the entire StyleSheet.create block with this updated version that includes the newPostWrapper style:
const styles = StyleSheet.create({
container: {
flex: 1,
},
newPostWrapper: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingBottom: 30,
backgroundColor: '#f5f5f5',
},
})
Why position the input bar absolutely?
Using position: 'absolute' with bottom: 0 pins the post input to the bottom of the screen. This way the feed scrolls independently behind it, and the input is always reachable without scrolling.
The paddingBottom: 30 adds spacing so the input does not sit behind the iPhone's home indicator bar.
- Save App.tsx.
- Check your iPhone. You should see a white input bar at the bottom of the screen with placeholder text that says "What's on your mind?" and a blue Post button.
- Type a short message and tap Post. Your message should appear immediately in the feed above.
Test real-time updates across devices
In your Expo terminal, press w to open the app in a web browser. Sign in with the same account and create a post from the browser. It should appear on your iPhone in real time thanks to the Supabase Realtime subscription you set up in the previous step.
Post not appearing in the feed?
- If you see an alert with a permissions error, check that your RLS INSERT policy in Supabase matches user_id = auth.uid(). The user_id you pass in the insert must match the authenticated user.
- Make sure you passed session as a prop to both Feed and NewPost in App.tsx.
- If the post submits but the feed does not update, confirm that your Realtime subscription in Feed.tsx is listening for the INSERT event on the posts table.
Help me debug why my post is not appearing in the BuzzFeed feed.
✔️ Awesome, I've got everything!
Great. Double check that both components/NewPost.tsx and App.tsx are saved.
ⓧ I'd like to double check the full code
Here is the complete components/NewPost.tsx:
import { useState } from 'react'
import { Alert, StyleSheet, TextInput, TouchableOpacity, View, Text } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
export default function NewPost({ session }: { session: Session }) {
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
async function createPost() {
if (!content.trim()) {
Alert.alert('Error', 'Post cannot be empty')
return
}
setLoading(true)
const { error } = await supabase.from('posts').insert({
content: content.trim(),
user_id: session.user.id,
author_email: session.user.email,
})
if (error) {
Alert.alert('Error', error.message)
} else {
setContent('')
}
setLoading(false)
}
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="What's on your mind?"
value={content}
onChangeText={setContent}
multiline
maxLength={280}
/>
<TouchableOpacity
style={styles.button}
onPress={createPost}
disabled={loading || !content.trim()}
>
<Text style={styles.buttonText}>Post</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
padding: 12,
marginHorizontal: 12,
marginTop: 12,
borderRadius: 10,
flexDirection: 'row',
alignItems: 'center',
},
input: {
flex: 1,
fontSize: 16,
padding: 8,
maxHeight: 80,
},
button: {
backgroundColor: '#4a90d9',
borderRadius: 20,
paddingVertical: 8,
paddingHorizontal: 16,
marginLeft: 8,
},
buttonText: {
color: '#fff',
fontWeight: '600',
fontSize: 14,
},
})
And here is the complete App.tsx:
import { useEffect, useState } from 'react'
import { View, StyleSheet } from 'react-native'
import { Session } from '@supabase/supabase-js'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
import Feed from './components/Feed'
import NewPost from './components/NewPost'
export default function App() {
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
return () => subscription.unsubscribe()
}, [])
if (!session) {
return <Auth />
}
return (
<View style={styles.container}>
<Feed session={session} />
<View style={styles.newPostWrapper}>
<NewPost session={session} />
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
newPostWrapper: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingBottom: 30,
backgroundColor: '#f5f5f5',
},
})
Your social feed app is fully functional. Users can sign up, sign in, create posts, and see everyone's posts update in real time. Next, you will take on the Secret Mission and add a like system so users can interact with each other's posts.
Secret mission
Add a Like System
Your feed works great, but social media is not just about posting. Every major platform lets users react to content. Time to add a like system with optimistic UI updates so taps feel instant.
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 the Supabase Free tier, so there are no ongoing costs unless you exceed 500 MB of database storage or 50,000 monthly active users.
Resources you used:
- Supabase project named "buzzfeed" (cloud, free tier).
- Local BuzzFeed project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the app.
- Your Supabase project stays active on the free tier indefinitely.
- If you stop using it for 7 days, Supabase automatically pauses the project. You can resume it with one click from the dashboard whenever you want.
- The free tier gives you 2 active projects. Paused projects do not count toward that limit.
- Your local BuzzFeed folder stays on your computer and takes up minimal disk space.
Pause - I'll come back to this later
Manually pause your Supabase project to free up one of your 2 active project slots. Your data stays intact.
- Open the Supabase dashboard at https://supabase.com/dashboard.
- Select your buzzfeed project.
- Click the Pause project button on the project's home page.
- Your project can be restored within 90 days with one click.
- Stop the Expo development server in your terminal by pressing Ctrl+C.
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 and select your buzzfeed project.
- Go to Settings in the sidebar, then General.
- Scroll to the bottom and click Delete Project. Confirm the deletion when prompted.
Deletion is permanent
Deleting your Supabase project permanently removes all associated data, including the posts table, likes table, user accounts, and authentication configuration. This action cannot be undone.
- Delete your local project folder. Open PowerShell and run:
Remove-Item -Recurse -Force ~\Desktop\BuzzFeed
On macOS or Linux, run this instead:
rm -rf ~/Desktop/BuzzFeed
- Stop the Expo development server if it is still running by pressing Ctrl+C in the terminal.
That's a Wrap!
That's a Wrap!
Nice work. You just built a fully authenticated, real-time social feed app running live on your iPhone.
You've learned how to:
- Built and ran a React Native app live on your physical iPhone using Expo Go.
- Created a Supabase project with email/password authentication, a Postgres database, and Row Level Security.
- Built a real-time social feed that updates instantly when new posts are created, using Supabase Realtime subscriptions.
- Secret Mission: Implemented a like/unlike system with a separate likes table and optimistic UI updates.
Ready to quiz yourself?