about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-06-21 21:38:04 -0700
committerGitHub <noreply@github.com>2024-06-21 21:38:04 -0700
commitf089f4578131e83cd177b7809ce0f7b75779dfdc (patch)
tree51978aede2040fb8dc319f0749d3de77c7811fbe
parent35f64535cb8dfa0fe46e740a6398f3b991ecfbc7 (diff)
downloadvoidsky-f089f4578131e83cd177b7809ce0f7b75779dfdc.tar.zst
Starter Packs (#4332)
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r--__tests__/lib/string.test.ts181
-rw-r--r--app.config.js18
-rw-r--r--assets/icons/qrCode_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/starterPack.svg1
-rw-r--r--assets/icons/starter_pack_icon.svg1
-rw-r--r--assets/logo.pngbin0 -> 10126 bytes
-rw-r--r--bskyweb/cmd/bskyweb/server.go4
-rw-r--r--bskyweb/static/.well-known/apple-app-site-association6
-rw-r--r--modules/BlueskyClip/AppDelegate.swift32
-rw-r--r--modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.pngbin0 -> 473960 bytes
-rw-r--r--modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json14
-rw-r--r--modules/BlueskyClip/Images.xcassets/Contents.json6
-rw-r--r--modules/BlueskyClip/ViewController.swift133
-rw-r--r--modules/expo-bluesky-swiss-army/android/build.gradle47
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt10
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt54
-rw-r--r--modules/expo-bluesky-swiss-army/expo-module.config.json12
-rw-r--r--modules/expo-bluesky-swiss-army/index.ts4
-rw-r--r--modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift23
-rw-r--r--modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec21
-rw-r--r--modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift7
-rw-r--r--modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts18
-rw-r--r--modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts16
-rw-r--r--modules/expo-bluesky-swiss-army/src/NotImplemented.ts16
-rw-r--r--modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts9
-rw-r--r--modules/expo-bluesky-swiss-army/src/Referrer/index.ts7
-rw-r--r--modules/expo-bluesky-swiss-army/src/Referrer/types.ts7
-rw-r--r--package.json5
-rw-r--r--plugins/starterPackAppClipExtension/withAppEntitlements.js16
-rw-r--r--plugins/starterPackAppClipExtension/withClipEntitlements.js32
-rw-r--r--plugins/starterPackAppClipExtension/withClipInfoPlist.js38
-rw-r--r--plugins/starterPackAppClipExtension/withFiles.js40
-rw-r--r--plugins/starterPackAppClipExtension/withStarterPackAppClip.js40
-rw-r--r--plugins/starterPackAppClipExtension/withXcodeTarget.js91
-rwxr-xr-xscripts/updateExtensions.sh9
-rw-r--r--src/App.native.tsx9
-rw-r--r--src/App.web.tsx9
-rw-r--r--src/Navigation.tsx23
-rw-r--r--src/components/LinearGradientBackground.tsx23
-rw-r--r--src/components/NewskieDialog.tsx70
-rw-r--r--src/components/ProfileCard.tsx91
-rw-r--r--src/components/ReportDialog/SelectReportOptionView.tsx3
-rw-r--r--src/components/ReportDialog/types.ts2
-rw-r--r--src/components/StarterPack/Main/FeedsList.tsx68
-rw-r--r--src/components/StarterPack/Main/ProfilesList.tsx119
-rw-r--r--src/components/StarterPack/ProfileStarterPacks.tsx320
-rw-r--r--src/components/StarterPack/QrCode.tsx119
-rw-r--r--src/components/StarterPack/QrCodeDialog.tsx201
-rw-r--r--src/components/StarterPack/ShareDialog.tsx180
-rw-r--r--src/components/StarterPack/StarterPackCard.tsx117
-rw-r--r--src/components/StarterPack/Wizard/ScreenTransition.tsx31
-rw-r--r--src/components/StarterPack/Wizard/WizardEditListDialog.tsx152
-rw-r--r--src/components/StarterPack/Wizard/WizardListCard.tsx182
-rw-r--r--src/components/forms/TextField.tsx2
-rw-r--r--src/components/hooks/useStarterPackEntry.native.ts68
-rw-r--r--src/components/hooks/useStarterPackEntry.ts29
-rw-r--r--src/components/icons/QrCode.tsx5
-rw-r--r--src/components/icons/StarterPack.tsx8
-rw-r--r--src/components/icons/TEMPLATE.tsx31
-rw-r--r--src/components/icons/common.ts32
-rw-r--r--src/components/icons/common.tsx59
-rw-r--r--src/lib/browser.native.ts1
-rw-r--r--src/lib/browser.ts2
-rw-r--r--src/lib/generate-starterpack.ts164
-rw-r--r--src/lib/hooks/useBottomBarOffset.ts14
-rw-r--r--src/lib/hooks/useNotificationHandler.ts2
-rw-r--r--src/lib/moderation/create-sanitized-display-name.ts21
-rw-r--r--src/lib/moderation/useReportOptions.ts9
-rw-r--r--src/lib/routes/links.ts17
-rw-r--r--src/lib/routes/types.ts12
-rw-r--r--src/lib/statsig/events.ts35
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/lib/strings/starter-pack.ts101
-rw-r--r--src/routes.ts4
-rw-r--r--src/screens/Login/LoginForm.tsx3
-rw-r--r--src/screens/Login/ScreenTransition.tsx11
-rw-r--r--src/screens/Onboarding/StepFinished.tsx117
-rw-r--r--src/screens/Profile/Header/DisplayName.tsx6
-rw-r--r--src/screens/Signup/index.tsx40
-rw-r--r--src/screens/StarterPack/StarterPackLandingScreen.tsx378
-rw-r--r--src/screens/StarterPack/StarterPackScreen.tsx627
-rw-r--r--src/screens/StarterPack/Wizard/State.tsx163
-rw-r--r--src/screens/StarterPack/Wizard/StepDetails.tsx84
-rw-r--r--src/screens/StarterPack/Wizard/StepFeeds.tsx113
-rw-r--r--src/screens/StarterPack/Wizard/StepFinished.tsx0
-rw-r--r--src/screens/StarterPack/Wizard/StepProfiles.tsx101
-rw-r--r--src/screens/StarterPack/Wizard/index.tsx575
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/index.tsx5
-rw-r--r--src/state/preferences/used-starter-packs.tsx37
-rw-r--r--src/state/queries/actor-search.ts46
-rw-r--r--src/state/queries/actor-starter-packs.ts47
-rw-r--r--src/state/queries/feed.ts17
-rw-r--r--src/state/queries/list-members.ts19
-rw-r--r--src/state/queries/notifications/feed.ts11
-rw-r--r--src/state/queries/notifications/types.ts49
-rw-r--r--src/state/queries/notifications/util.ts83
-rw-r--r--src/state/queries/profile-lists.ts10
-rw-r--r--src/state/queries/shorten-link.ts23
-rw-r--r--src/state/queries/starter-packs.ts317
-rw-r--r--src/state/session/agent.ts12
-rw-r--r--src/state/shell/logged-out.tsx17
-rw-r--r--src/state/shell/starter-pack.tsx25
-rw-r--r--src/view/com/auth/LoggedOut.tsx42
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx3
-rw-r--r--src/view/com/notifications/FeedItem.tsx87
-rw-r--r--src/view/com/profile/FollowButton.tsx11
-rw-r--r--src/view/com/profile/ProfileCard.tsx6
-rw-r--r--src/view/com/profile/ProfileSubpageHeader.tsx10
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Profile.tsx115
-rw-r--r--src/view/screens/Storybook/Icons.tsx8
-rw-r--r--src/view/shell/desktop/LeftNav.tsx8
-rw-r--r--yarn.lock72
115 files changed, 6329 insertions, 230 deletions
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 30072ccb1..0da9551e3 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -1,6 +1,11 @@
 import {RichText} from '@atproto/api'
 
 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
+import {
+  createStarterPackGooglePlayUri,
+  createStarterPackLinkFromAndroidReferrer,
+  parseStarterPackUri,
+} from 'lib/strings/starter-pack'
 import {cleanError} from '../../src/lib/strings/errors'
 import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
 import {enforceLen} from '../../src/lib/strings/helpers'
@@ -796,3 +801,179 @@ describe('parseEmbedPlayerFromUrl', () => {
     }
   })
 })
+
+describe('createStarterPackLinkFromAndroidReferrer', () => {
+  const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey'
+
+  it('returns a link when input contains utm_source and utm_content', () => {
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey',
+      ),
+    ).toEqual(validOutput)
+
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey',
+      ),
+    ).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey')
+  })
+
+  it('returns a link when input contains utm_source and utm_content in different order', () => {
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky',
+      ),
+    ).toEqual(validOutput)
+  })
+
+  it('returns a link when input contains other parameters as well', () => {
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey',
+      ),
+    ).toEqual(validOutput)
+  })
+
+  it('returns null when utm_source is not present', () => {
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_content=starterpack_haileyok.com_rkey',
+      ),
+    ).toEqual(null)
+  })
+
+  it('returns null when utm_content is not present', () => {
+    expect(
+      createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'),
+    ).toEqual(null)
+  })
+
+  it('returns null when utm_content is malformed', () => {
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_content=starterpack_haileyok.com',
+      ),
+    ).toEqual(null)
+
+    expect(
+      createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'),
+    ).toEqual(null)
+
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_content=starterpack_haileyok.com_rkey_more',
+      ),
+    ).toEqual(null)
+
+    expect(
+      createStarterPackLinkFromAndroidReferrer(
+        'utm_content=notastarterpack_haileyok.com_rkey',
+      ),
+    ).toEqual(null)
+  })
+})
+
+describe('parseStarterPackHttpUri', () => {
+  const baseUri = 'https://bsky.app/start'
+
+  it('returns a valid at uri when http uri is valid', () => {
+    const validHttpUri = `${baseUri}/haileyok.com/rkey`
+    expect(parseStarterPackUri(validHttpUri)).toEqual({
+      name: 'haileyok.com',
+      rkey: 'rkey',
+    })
+
+    const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting`
+    expect(parseStarterPackUri(validHttpUri2)).toEqual({
+      name: 'haileyok.com',
+      rkey: 'ilovetesting',
+    })
+
+    const validHttpUri3 = `${baseUri}/testlover9000.com/rkey`
+    expect(parseStarterPackUri(validHttpUri3)).toEqual({
+      name: 'testlover9000.com',
+      rkey: 'rkey',
+    })
+  })
+
+  it('returns null when there is no rkey', () => {
+    const validHttpUri = `${baseUri}/haileyok.com`
+    expect(parseStarterPackUri(validHttpUri)).toEqual(null)
+  })
+
+  it('returns null when there is an extra path', () => {
+    const validHttpUri = `${baseUri}/haileyok.com/rkey/other`
+    expect(parseStarterPackUri(validHttpUri)).toEqual(null)
+  })
+
+  it('returns null when there is no handle or rkey', () => {
+    const validHttpUri = `${baseUri}`
+    expect(parseStarterPackUri(validHttpUri)).toEqual(null)
+  })
+
+  it('returns null when the route is not /start or /starter-pack', () => {
+    const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey'
+    expect(parseStarterPackUri(validHttpUri)).toEqual({
+      name: 'haileyok.com',
+      rkey: 'rkey',
+    })
+
+    const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey'
+    expect(parseStarterPackUri(validHttpUri2)).toEqual({
+      name: 'haileyok.com',
+      rkey: 'rkey',
+    })
+
+    const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey'
+    expect(parseStarterPackUri(invalidHttpUri)).toEqual(null)
+  })
+
+  it('returns the at uri when the input is a valid starterpack at uri', () => {
+    const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
+    expect(parseStarterPackUri(validAtUri)).toEqual({
+      name: 'did:123',
+      rkey: 'rkey',
+    })
+  })
+
+  it('returns null when the at uri has no rkey', () => {
+    const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
+    expect(parseStarterPackUri(validAtUri)).toEqual(null)
+  })
+
+  it('returns null when the collection is not app.bsky.graph.starterpack', () => {
+    const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
+    expect(parseStarterPackUri(validAtUri)).toEqual(null)
+  })
+
+  it('returns null when the input is undefined', () => {
+    expect(parseStarterPackUri(undefined)).toEqual(null)
+  })
+})
+
+describe('createStarterPackGooglePlayUri', () => {
+  const base =
+    'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_'
+
+  it('returns valid google play uri when input is valid', () => {
+    expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual(
+      `${base}name_rkey`,
+    )
+  })
+
+  it('returns null when no rkey is supplied', () => {
+    // @ts-expect-error test
+    expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null)
+  })
+
+  it('returns null when no name or rkey are supplied', () => {
+    // @ts-expect-error test
+    expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null)
+  })
+
+  it('returns null when rkey is supplied but no name', () => {
+    // @ts-expect-error test
+    expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
+  })
+})
diff --git a/app.config.js b/app.config.js
index eafacc6cc..57d430586 100644
--- a/app.config.js
+++ b/app.config.js
@@ -39,6 +39,17 @@ module.exports = function (config) {
   const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
   const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
 
+  const ASSOCIATED_DOMAINS = [
+    'applinks:bsky.app',
+    'applinks:staging.bsky.app',
+    'appclips:bsky.app',
+    'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
+    // When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
+    ...(IS_DEV || IS_TESTFLIGHT
+      ? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com']
+      : []),
+  ]
+
   const UPDATES_CHANNEL = IS_TESTFLIGHT
     ? 'testflight'
     : IS_PRODUCTION
@@ -83,7 +94,7 @@ module.exports = function (config) {
           NSPhotoLibraryUsageDescription:
             'Used for profile pictures, posts, and other kinds of content',
         },
-        associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
+        associatedDomains: ASSOCIATED_DOMAINS,
         splash: {
           ...SPLASH_CONFIG,
           dark: DARK_SPLASH_CONFIG,
@@ -202,6 +213,7 @@ module.exports = function (config) {
             sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
           },
         ],
+        './plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
         './plugins/withAndroidManifestPlugin.js',
         './plugins/withAndroidManifestFCMIconPlugin.js',
         './plugins/withAndroidStylesWindowBackgroundPlugin.js',
@@ -234,6 +246,10 @@ module.exports = function (config) {
                       ],
                     },
                   },
+                  {
+                    targetName: 'BlueskyClip',
+                    bundleIdentifier: 'xyz.blueskyweb.app.AppClip',
+                  },
                 ],
               },
             },
diff --git a/assets/icons/qrCode_stroke2_corner0_rounded.svg b/assets/icons/qrCode_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..b17db3953
--- /dev/null
+++ b/assets/icons/qrCode_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg
new file mode 100644
index 000000000..7f0df5595
--- /dev/null
+++ b/assets/icons/starterPack.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/starter_pack_icon.svg b/assets/icons/starter_pack_icon.svg
new file mode 100644
index 000000000..47a2f49b6
--- /dev/null
+++ b/assets/icons/starter_pack_icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
\ No newline at end of file
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 000000000..cc2d66034
--- /dev/null
+++ b/assets/logo.png
Binary files differdiff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 6d32e0e21..96fb07ddf 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -223,6 +223,10 @@ func serve(cctx *cli.Context) error {
 	e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
 	e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
 
+    // starter packs
+	e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric)
+	e.GET("/start/:handleOrDID/:rkey", server.WebGeneric)
+
 	if linkHost != "" {
 		linkUrl, err := url.Parse(linkHost)
 		if err != nil {
diff --git a/bskyweb/static/.well-known/apple-app-site-association b/bskyweb/static/.well-known/apple-app-site-association
index 232acdf25..0a05fa35f 100644
--- a/bskyweb/static/.well-known/apple-app-site-association
+++ b/bskyweb/static/.well-known/apple-app-site-association
@@ -1,6 +1,8 @@
 {
   "applinks": {
-    "apps": [],
+    "appclips": {
+      "apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
+    },
     "details": [
       {
         "appID": "B3LX46C5HS.xyz.blueskyweb.app",
@@ -10,4 +12,4 @@
       }
     ]
   }
-}
\ No newline at end of file
+}
diff --git a/modules/BlueskyClip/AppDelegate.swift b/modules/BlueskyClip/AppDelegate.swift
new file mode 100644
index 000000000..684194953
--- /dev/null
+++ b/modules/BlueskyClip/AppDelegate.swift
@@ -0,0 +1,32 @@
+import UIKit
+
+@main
+class AppDelegate: UIResponder, UIApplicationDelegate {
+  var window: UIWindow?
+  var controller: ViewController?
+
+  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+    let window = UIWindow()
+    self.window = UIWindow()
+
+    let controller = ViewController(window: window)
+    self.controller = controller
+
+    window.rootViewController = self.controller
+    window.makeKeyAndVisible()
+
+    return true
+  }
+
+  func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
+    self.controller?.handleURL(url: url)
+    return true
+  }
+
+  func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
+      if let incomingURL = userActivity.webpageURL {
+        self.controller?.handleURL(url: incomingURL)
+      }
+      return true
+  }
+}
diff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
new file mode 100644
index 000000000..75ce4b813
--- /dev/null
+++ b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
Binary files differdiff --git a/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..c3bb428db
--- /dev/null
+++ b/modules/BlueskyClip/Images.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+  "images" : [
+    {
+      "filename" : "App-Icon-1024x1024@1x.png",
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/modules/BlueskyClip/Images.xcassets/Contents.json b/modules/BlueskyClip/Images.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/modules/BlueskyClip/Images.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/modules/BlueskyClip/ViewController.swift b/modules/BlueskyClip/ViewController.swift
new file mode 100644
index 000000000..b178644b8
--- /dev/null
+++ b/modules/BlueskyClip/ViewController.swift
@@ -0,0 +1,133 @@
+import UIKit
+import WebKit
+import StoreKit
+
+class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
+  let defaults = UserDefaults(suiteName: "group.app.bsky")
+
+  var window: UIWindow
+  var webView: WKWebView?
+
+  var prevUrl: URL?
+  var starterPackUrl: URL?
+
+  init(window: UIWindow) {
+    self.window = window
+    super.init(nibName: nil, bundle: nil)
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+
+  override func viewDidLoad() {
+    super.viewDidLoad()
+
+    let contentController = WKUserContentController()
+    contentController.add(self, name: "onMessage")
+    let configuration = WKWebViewConfiguration()
+    configuration.userContentController = contentController
+
+    let webView = WKWebView(frame: self.view.bounds, configuration: configuration)
+    webView.translatesAutoresizingMaskIntoConstraints = false
+    webView.contentMode = .scaleToFill
+    webView.navigationDelegate = self
+    self.view.addSubview(webView)
+    self.webView = webView
+  }
+
+  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+    guard let response = message.body as? String,
+          let data = response.data(using: .utf8),
+          let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
+      return
+    }
+
+    switch payload.action {
+    case .present:
+      guard let url = self.starterPackUrl else {
+        return
+      }
+
+      self.presentAppStoreOverlay()
+      defaults?.setValue(url.absoluteString, forKey: "starterPackUri")
+
+    case .store:
+      guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else {
+        return
+      }
+
+      self.defaults?.setValue(jsonToStore, forKey: keyToStoreAs)
+    }
+  }
+
+  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
+    // Detect when we land on the right URL. This is incase of a short link opening the app clip
+    guard let url = navigationAction.request.url else {
+      return .allow
+    }
+
+    // Store the previous one to compare later, but only set starterPackUrl when we find the right one
+    prevUrl = url
+    // pathComponents starts with "/" as the first component, then each path name. so...
+    // ["/", "start", "name", "rkey"]
+    if url.pathComponents.count == 4,
+       url.pathComponents[1] == "start" {
+      self.starterPackUrl = url
+    }
+
+    return .allow
+  }
+
+  func handleURL(url: URL) {
+    let urlString = "\(url.absoluteString)?clip=true"
+    if let url = URL(string: urlString) {
+      self.webView?.load(URLRequest(url: url))
+    }
+  }
+
+  func presentAppStoreOverlay() {
+    guard let windowScene = self.window.windowScene else {
+      return
+    }
+
+    let configuration = SKOverlay.AppClipConfiguration(position: .bottomRaised)
+    let overlay = SKOverlay(configuration: configuration)
+
+    overlay.present(in: windowScene)
+  }
+
+  func getHost(_ url: URL?) -> String? {
+    if #available(iOS 16.0, *) {
+      return url?.host()
+    } else {
+      return url?.host
+    }
+  }
+
+  func getQuery(_ url: URL?) -> String? {
+    if #available(iOS 16.0, *) {
+      return url?.query()
+    } else {
+      return url?.query
+    }
+  }
+
+  func urlMatchesPrevious(_ url: URL?) -> Bool {
+    if #available(iOS 16.0, *) {
+      return url?.query() == prevUrl?.query() && url?.host() == prevUrl?.host() && url?.query() == prevUrl?.query()
+    } else {
+      return url?.query == prevUrl?.query && url?.host == prevUrl?.host && url?.query == prevUrl?.query
+    }
+  }
+}
+
+struct WebViewActionPayload: Decodable {
+  enum Action: String, Decodable {
+    case present, store
+  }
+
+  let action: Action
+  let keyToStoreAs: String?
+  let jsonToStore: String?
+}
diff --git a/modules/expo-bluesky-swiss-army/android/build.gradle b/modules/expo-bluesky-swiss-army/android/build.gradle
new file mode 100644
index 000000000..b031cde57
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/build.gradle
@@ -0,0 +1,47 @@
+apply plugin: 'com.android.library'
+
+group = 'expo.modules.blueskyswissarmy'
+version = '0.6.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
+// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
+// Most of the time, you may like to manage the Android SDK versions yourself.
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+  useDefaultAndroidSdkVersions()
+} else {
+  buildscript {
+    // Simple helper that allows the root project to override versions declared by this library.
+    ext.safeExtGet = { prop, fallback ->
+      rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+    }
+  }
+  project.android {
+    compileSdkVersion safeExtGet("compileSdkVersion", 34)
+    defaultConfig {
+      minSdkVersion safeExtGet("minSdkVersion", 21)
+      targetSdkVersion safeExtGet("targetSdkVersion", 34)
+    }
+  }
+}
+
+android {
+  namespace "expo.modules.blueskyswissarmy"
+  defaultConfig {
+    versionCode 1
+    versionName "0.6.0"
+  }
+  lintOptions {
+    abortOnError false
+  }
+}
+
+dependencies {
+  implementation("com.android.installreferrer:installreferrer:2.2")
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
new file mode 100644
index 000000000..29017f17a
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
@@ -0,0 +1,10 @@
+package expo.modules.blueskyswissarmy.deviceprefs
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskyDevicePrefsModule : Module() {
+  override fun definition() = ModuleDefinition {
+    Name("ExpoBlueskyDevicePrefs")
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt
new file mode 100644
index 000000000..3589b364e
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt
@@ -0,0 +1,54 @@
+package expo.modules.blueskyswissarmy.referrer
+
+import android.util.Log
+import com.android.installreferrer.api.InstallReferrerClient
+import com.android.installreferrer.api.InstallReferrerStateListener
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.Promise
+
+class ExpoBlueskyReferrerModule : Module() {
+  override fun definition() = ModuleDefinition {
+    Name("ExpoBlueskyReferrer")
+
+    AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
+      val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
+      referrerClient.startConnection(object : InstallReferrerStateListener {
+        override fun onInstallReferrerSetupFinished(responseCode: Int) {
+          if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
+            Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
+
+            val response = referrerClient.installReferrer
+            Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}")
+
+            promise.resolve(
+              mapOf(
+                "installReferrer" to response.installReferrer,
+                "clickTimestamp" to response.referrerClickTimestampSeconds,
+                "installTimestamp" to response.installBeginTimestampSeconds
+              )
+            )
+          } else {
+            Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
+            promise.reject(
+              "ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
+              "Failed to get referrer info",
+              Exception("Failed to get referrer info")
+            )
+          }
+          referrerClient.endConnection()
+        }
+
+        override fun onInstallReferrerServiceDisconnected() {
+          Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.")
+          referrerClient.endConnection()
+          promise.reject(
+            "ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
+            "Failed to get referrer info",
+            Exception("Failed to get referrer info")
+          )
+        }
+      })
+    }
+  }
+}
\ No newline at end of file
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
new file mode 100644
index 000000000..730bc6114
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -0,0 +1,12 @@
+{
+  "platforms": ["ios", "tvos", "android", "web"],
+  "ios": {
+    "modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"]
+  },
+  "android": {
+    "modules": [
+      "expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule",
+      "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
+    ]
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
new file mode 100644
index 000000000..1b2f89249
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -0,0 +1,4 @@
+import * as DevicePrefs from './src/DevicePrefs'
+import * as Referrer from './src/Referrer'
+
+export {DevicePrefs, Referrer}
diff --git a/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
new file mode 100644
index 000000000..b13a9fe3f
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
@@ -0,0 +1,23 @@
+import ExpoModulesCore
+
+public class ExpoBlueskyDevicePrefsModule: Module {
+  func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
+    if useAppGroup {
+      return UserDefaults(suiteName: "group.app.bsky")
+    } else {
+      return UserDefaults.standard
+    }
+  }
+
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBlueskyDevicePrefs")
+
+    AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
+      return self.getDefaults(useAppGroup)?.string(forKey: key)
+    }
+
+    AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
+      self.getDefaults(useAppGroup)?.setValue(value, forKey: key)
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
new file mode 100644
index 000000000..be4b0eae4
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+  s.name           = 'ExpoBlueskySwissArmy'
+  s.version        = '1.0.0'
+  s.summary        = 'A collection of native tools for Bluesky'
+  s.description    = 'A collection of native tools for Bluesky'
+  s.author         = ''
+  s.homepage       = 'https://github.com/bluesky-social/social-app'
+  s.platforms      = { :ios => '13.4', :tvos => '13.4' }
+  s.source         = { git: '' }
+  s.static_framework = true
+
+  s.dependency 'ExpoModulesCore'
+
+  # Swift/Objective-C compatibility
+  s.pod_target_xcconfig = {
+    'DEFINES_MODULE' => 'YES',
+    'SWIFT_COMPILATION_MODE' => 'wholemodule'
+  }
+
+  s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift
new file mode 100644
index 000000000..fd28c51e6
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Referrer/ExpoBlueskyReferrerModule.swift
@@ -0,0 +1,7 @@
+import ExpoModulesCore
+
+public class ExpoBlueskyReferrerModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBlueskyReferrer")
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
new file mode 100644
index 000000000..427185086
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
@@ -0,0 +1,18 @@
+import {requireNativeModule} from 'expo-modules-core'
+
+const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs')
+
+export function getStringValueAsync(
+  key: string,
+  useAppGroup?: boolean,
+): Promise<string | null> {
+  return NativeModule.getStringValueAsync(key, useAppGroup)
+}
+
+export function setStringValueAsync(
+  key: string,
+  value: string | null,
+  useAppGroup?: boolean,
+): Promise<void> {
+  return NativeModule.setStringValueAsync(key, value, useAppGroup)
+}
diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
new file mode 100644
index 000000000..f1eee6c28
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
@@ -0,0 +1,16 @@
+import {NotImplementedError} from '../NotImplemented'
+
+export function getStringValueAsync(
+  key: string,
+  useAppGroup?: boolean,
+): Promise<string | null> {
+  throw new NotImplementedError({key, useAppGroup})
+}
+
+export function setStringValueAsync(
+  key: string,
+  value: string | null,
+  useAppGroup?: boolean,
+): Promise<string | null> {
+  throw new NotImplementedError({key, value, useAppGroup})
+}
diff --git a/modules/expo-bluesky-swiss-army/src/NotImplemented.ts b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts
new file mode 100644
index 000000000..876cd7b32
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/NotImplemented.ts
@@ -0,0 +1,16 @@
+import {Platform} from 'react-native'
+
+export class NotImplementedError extends Error {
+  constructor(params = {}) {
+    if (__DEV__) {
+      const caller = new Error().stack?.split('\n')[2]
+      super(
+        `Not implemented on ${Platform.OS}. Given params: ${JSON.stringify(
+          params,
+        )} ${caller}`,
+      )
+    } else {
+      super('Not implemented')
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts
new file mode 100644
index 000000000..06dfd2d09
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts
@@ -0,0 +1,9 @@
+import {requireNativeModule} from 'expo'
+
+import {GooglePlayReferrerInfo} from './types'
+
+export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
+
+export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
+  return NativeModule.getGooglePlayReferrerInfoAsync()
+}
diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts
new file mode 100644
index 000000000..255398552
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts
@@ -0,0 +1,7 @@
+import {NotImplementedError} from '../NotImplemented'
+import {GooglePlayReferrerInfo} from './types'
+
+// @ts-ignore throws
+export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
+  throw new NotImplementedError()
+}
diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/types.ts b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts
new file mode 100644
index 000000000..55faaff4d
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts
@@ -0,0 +1,7 @@
+export type GooglePlayReferrerInfo =
+  | {
+      installReferrer?: string
+      clickTimestamp?: number
+      installTimestamp?: number
+    }
+  | undefined
diff --git a/package.json b/package.json
index bcd5a1d37..657770309 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
   },
   "dependencies": {
-    "@atproto/api": "^0.12.20",
+    "@atproto/api": "0.12.22-next.0",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -177,6 +177,7 @@
     "react-native-pager-view": "6.2.3",
     "react-native-picker-select": "^9.1.3",
     "react-native-progress": "bluesky-social/react-native-progress",
+    "react-native-qrcode-styled": "^0.3.1",
     "react-native-reanimated": "^3.11.0",
     "react-native-root-siblings": "^4.1.1",
     "react-native-safe-area-context": "4.10.1",
@@ -205,7 +206,7 @@
     "@babel/preset-env": "^7.20.0",
     "@babel/runtime": "^7.20.0",
     "@did-plc/server": "^0.0.1",
-    "@expo/config-plugins": "7.8.0",
+    "@expo/config-plugins": "8.0.4",
     "@expo/prebuild-config": "6.7.0",
     "@lingui/cli": "^4.5.0",
     "@lingui/macro": "^4.5.0",
diff --git a/plugins/starterPackAppClipExtension/withAppEntitlements.js b/plugins/starterPackAppClipExtension/withAppEntitlements.js
new file mode 100644
index 000000000..1bffd8208
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withAppEntitlements.js
@@ -0,0 +1,16 @@
+const {withEntitlementsPlist} = require('@expo/config-plugins')
+
+const withAppEntitlements = config => {
+  // eslint-disable-next-line no-shadow
+  return withEntitlementsPlist(config, async config => {
+    config.modResults['com.apple.security.application-groups'] = [
+      `group.app.bsky`,
+    ]
+    config.modResults[
+      'com.apple.developer.associated-appclip-app-identifiers'
+    ] = [`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}.AppClip`]
+    return config
+  })
+}
+
+module.exports = {withAppEntitlements}
diff --git a/plugins/starterPackAppClipExtension/withClipEntitlements.js b/plugins/starterPackAppClipExtension/withClipEntitlements.js
new file mode 100644
index 000000000..77636b5c9
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withClipEntitlements.js
@@ -0,0 +1,32 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withClipEntitlements = (config, {targetName}) => {
+  // eslint-disable-next-line no-shadow
+  return withInfoPlist(config, config => {
+    const entitlementsPath = path.join(
+      config.modRequest.platformProjectRoot,
+      targetName,
+      `${targetName}.entitlements`,
+    )
+
+    const appClipEntitlements = {
+      'com.apple.security.application-groups': [`group.app.bsky`],
+      'com.apple.developer.parent-application-identifiers': [
+        `$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
+      ],
+      'com.apple.developer.associated-domains': config.ios.associatedDomains,
+    }
+
+    fs.mkdirSync(path.dirname(entitlementsPath), {
+      recursive: true,
+    })
+    fs.writeFileSync(entitlementsPath, plist.default.build(appClipEntitlements))
+
+    return config
+  })
+}
+
+module.exports = {withClipEntitlements}
diff --git a/plugins/starterPackAppClipExtension/withClipInfoPlist.js b/plugins/starterPackAppClipExtension/withClipInfoPlist.js
new file mode 100644
index 000000000..59fbed1a9
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withClipInfoPlist.js
@@ -0,0 +1,38 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withClipInfoPlist = (config, {targetName}) => {
+  // eslint-disable-next-line no-shadow
+  return withInfoPlist(config, config => {
+    const targetPath = path.join(
+      config.modRequest.platformProjectRoot,
+      targetName,
+      'Info.plist',
+    )
+
+    const newPlist = plist.default.build({
+      NSAppClip: {
+        NSAppClipRequestEphemeralUserNotification: false,
+        NSAppClipRequestLocationConfirmation: false,
+      },
+      UILaunchScreen: {},
+      CFBundleName: '$(PRODUCT_NAME)',
+      CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
+      CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
+      CFBundleExecutable: '$(EXECUTABLE_NAME)',
+      CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
+      CFBundleShortVersionString: config.version,
+      CFBundleIconName: 'AppIcon',
+      UIViewControllerBasedStatusBarAppearance: 'NO',
+    })
+
+    fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+    fs.writeFileSync(targetPath, newPlist)
+
+    return config
+  })
+}
+
+module.exports = {withClipInfoPlist}
diff --git a/plugins/starterPackAppClipExtension/withFiles.js b/plugins/starterPackAppClipExtension/withFiles.js
new file mode 100644
index 000000000..ad99f5ae4
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withFiles.js
@@ -0,0 +1,40 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const FILES = ['AppDelegate.swift', 'ViewController.swift']
+
+const withFiles = (config, {targetName}) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    const basePath = path.join(
+      config.modRequest.projectRoot,
+      'modules',
+      targetName,
+    )
+
+    for (const file of FILES) {
+      const sourcePath = path.join(basePath, file)
+      const targetPath = path.join(
+        config.modRequest.platformProjectRoot,
+        targetName,
+        file,
+      )
+
+      fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+      fs.copyFileSync(sourcePath, targetPath)
+    }
+
+    const imagesBasePath = path.join(basePath, 'Images.xcassets')
+    const imagesTargetPath = path.join(
+      config.modRequest.platformProjectRoot,
+      targetName,
+      'Images.xcassets',
+    )
+    fs.cpSync(imagesBasePath, imagesTargetPath, {recursive: true})
+
+    return config
+  })
+}
+
+module.exports = {withFiles}
diff --git a/plugins/starterPackAppClipExtension/withStarterPackAppClip.js b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js
new file mode 100644
index 000000000..1e3f0b702
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withStarterPackAppClip.js
@@ -0,0 +1,40 @@
+const {withPlugins} = require('@expo/config-plugins')
+const {withAppEntitlements} = require('./withAppEntitlements')
+const {withClipEntitlements} = require('./withClipEntitlements')
+const {withClipInfoPlist} = require('./withClipInfoPlist')
+const {withFiles} = require('./withFiles')
+const {withXcodeTarget} = require('./withXcodeTarget')
+
+const APP_CLIP_TARGET_NAME = 'BlueskyClip'
+
+const withStarterPackAppClip = config => {
+  return withPlugins(config, [
+    withAppEntitlements,
+    [
+      withClipEntitlements,
+      {
+        targetName: APP_CLIP_TARGET_NAME,
+      },
+    ],
+    [
+      withClipInfoPlist,
+      {
+        targetName: APP_CLIP_TARGET_NAME,
+      },
+    ],
+    [
+      withFiles,
+      {
+        targetName: APP_CLIP_TARGET_NAME,
+      },
+    ],
+    [
+      withXcodeTarget,
+      {
+        targetName: APP_CLIP_TARGET_NAME,
+      },
+    ],
+  ])
+}
+
+module.exports = withStarterPackAppClip
diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js
new file mode 100644
index 000000000..61d5f81b0
--- /dev/null
+++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js
@@ -0,0 +1,91 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+
+const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift']
+
+const withXcodeTarget = (config, {targetName}) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    const pbxProject = config.modResults
+
+    const target = pbxProject.addTarget(targetName, 'application', targetName)
+    target.pbxNativeTarget.productType = `"com.apple.product-type.application.on-demand-install-capable"`
+    pbxProject.addBuildPhase(
+      BUILD_PHASE_FILES.map(f => `${targetName}/${f}`),
+      'PBXSourcesBuildPhase',
+      'Sources',
+      target.uuid,
+      'application',
+      '"AppClips"',
+    )
+    pbxProject.addBuildPhase(
+      [`${targetName}/Images.xcassets`],
+      'PBXResourcesBuildPhase',
+      'Resources',
+      target.uuid,
+      'application',
+      '"AppClips"',
+    )
+
+    const pbxGroup = pbxProject.addPbxGroup([
+      'AppDelegate.swift',
+      'ViewController.swift',
+      'Images.xcassets',
+      `${targetName}.entitlements`,
+      'Info.plist',
+    ])
+
+    pbxProject.addFile(`${targetName}/Info.plist`, pbxGroup.uuid)
+    const configurations = pbxProject.pbxXCBuildConfigurationSection()
+    for (const key in configurations) {
+      if (typeof configurations[key].buildSettings !== 'undefined') {
+        const buildSettingsObj = configurations[key].buildSettings
+        if (
+          typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
+          buildSettingsObj.PRODUCT_NAME === `"${targetName}"`
+        ) {
+          buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
+          buildSettingsObj.INFOPLIST_FILE = `"${targetName}/Info.plist"`
+          buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${targetName}/${targetName}.entitlements"`
+          buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
+          buildSettingsObj.CURRENT_PROJECT_VERSION = `"${
+            process.env.BSKY_IOS_BUILD_NUMBER ?? '1'
+          }"`
+          buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
+          buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
+          buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.AppClip"`
+          buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
+          buildSettingsObj.SWIFT_VERSION = '5.0'
+          buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
+          buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
+          buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
+          buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
+        }
+      }
+    }
+
+    pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS', targetName)
+
+    if (!pbxProject.hash.project.objects.PBXTargetDependency) {
+      pbxProject.hash.project.objects.PBXTargetDependency = {}
+    }
+    if (!pbxProject.hash.project.objects.PBXContainerItemProxy) {
+      pbxProject.hash.project.objects.PBXContainerItemProxy = {}
+    }
+    pbxProject.addTargetDependency(pbxProject.getFirstTarget().uuid, [
+      target.uuid,
+    ])
+
+    pbxProject.addBuildPhase(
+      [`${targetName}.app`],
+      'PBXCopyFilesBuildPhase',
+      'Embed App Clips',
+      pbxProject.getFirstTarget().uuid,
+      'application',
+      '"AppClips"',
+    )
+
+    return config
+  })
+}
+
+module.exports = {withXcodeTarget}
diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh
index f3e972aa7..b01134eeb 100755
--- a/scripts/updateExtensions.sh
+++ b/scripts/updateExtensions.sh
@@ -1,6 +1,7 @@
 #!/bin/bash
 IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
 IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
+IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip"
 MODULES_DIRECTORY="./modules"
 
 if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
@@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
 else
   cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
 fi
+
+
+if [ ! -d $IOS_APP_CLIP_DIRECTORY ]; then
+  echo "$IOS_APP_CLIP_DIRECTORY not found inside of your iOS project."
+  exit 1
+else
+  cp -R $IOS_APP_CLIP_DIRECTORY $MODULES_DIRECTORY
+fi
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 4c73d8752..639276a12 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -46,11 +46,13 @@ import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
 import * as Toast from '#/view/com/util/Toast'
 import {Shell} from '#/view/shell'
 import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
 import {Provider as PortalProvider} from '#/components/Portal'
 import {Splash} from '#/Splash'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -67,6 +69,7 @@ function InnerApp() {
   const {_} = useLingui()
 
   useIntentHandler()
+  const hasCheckedReferrer = useStarterPackEntry()
 
   // init
   useEffect(() => {
@@ -98,7 +101,7 @@ function InnerApp() {
     <SafeAreaProvider initialMetrics={initialWindowMetrics}>
       <Alf theme={theme}>
         <ThemeProvider theme={theme}>
-          <Splash isReady={isReady}>
+          <Splash isReady={isReady && hasCheckedReferrer}>
             <RootSiblingParent>
               <React.Fragment
                 // Resets the entire tree below when it changes:
@@ -164,7 +167,9 @@ function App() {
                     <LightboxStateProvider>
                       <I18nProvider>
                         <PortalProvider>
-                          <InnerApp />
+                          <StarterPackProvider>
+                            <InnerApp />
+                          </StarterPackProvider>
                         </PortalProvider>
                       </I18nProvider>
                     </LightboxStateProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 00939c9eb..31a59d97d 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -35,11 +35,13 @@ import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import * as Toast from '#/view/com/util/Toast'
 import {ToastContainer} from '#/view/com/util/Toast.web'
 import {Shell} from '#/view/shell/index'
 import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
 import {Provider as PortalProvider} from '#/components/Portal'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
 import I18nProvider from './locale/i18nProvider'
@@ -52,6 +54,7 @@ function InnerApp() {
   const theme = useColorModeTheme()
   const {_} = useLingui()
   useIntentHandler()
+  const hasCheckedReferrer = useStarterPackEntry()
 
   // init
   useEffect(() => {
@@ -77,7 +80,7 @@ function InnerApp() {
   }, [_])
 
   // wait for session to resume
-  if (!isReady) return null
+  if (!isReady || !hasCheckedReferrer) return null
 
   return (
     <KeyboardProvider enabled={false}>
@@ -146,7 +149,9 @@ function App() {
                   <LightboxStateProvider>
                     <I18nProvider>
                       <PortalProvider>
-                        <InnerApp />
+                        <StarterPackProvider>
+                          <InnerApp />
+                        </StarterPackProvider>
                       </PortalProvider>
                     </I18nProvider>
                   </LightboxStateProvider>
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f2b7cd911..5cb4f4105 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag'
 import {ModerationScreen} from '#/screens/Moderation'
 import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
+import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
+import {Wizard} from '#/screens/StarterPack/Wizard'
 import {init as initAnalytics} from './lib/analytics/analytics'
 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
 import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -317,6 +319,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         getComponent={() => FeedsScreen}
         options={{title: title(msg`Feeds`)}}
       />
+      <Stack.Screen
+        name="StarterPack"
+        getComponent={() => StarterPackScreen}
+        options={{title: title(msg`Starter Pack`), requireAuth: true}}
+      />
+      <Stack.Screen
+        name="StarterPackWizard"
+        getComponent={() => Wizard}
+        options={{title: title(msg`Create a starter pack`), requireAuth: true}}
+      />
+      <Stack.Screen
+        name="StarterPackEdit"
+        getComponent={() => Wizard}
+        options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
+      />
     </>
   )
 }
@@ -371,6 +388,7 @@ function HomeTabNavigator() {
         contentStyle: pal.view,
       }}>
       <HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
+      <HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
       {commonScreens(HomeTab)}
     </HomeTab.Navigator>
   )
@@ -507,6 +525,11 @@ const FlatNavigator = () => {
         getComponent={() => MessagesScreen}
         options={{title: title(msg`Messages`), requireAuth: true}}
       />
+      <Flat.Screen
+        name="Start"
+        getComponent={() => HomeScreen}
+        options={{title: title(msg`Home`)}}
+      />
       {commonScreens(Flat as typeof HomeTab, numUnread)}
     </Flat.Navigator>
   )
diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx
new file mode 100644
index 000000000..f516b19f5
--- /dev/null
+++ b/src/components/LinearGradientBackground.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import {LinearGradient} from 'expo-linear-gradient'
+
+import {gradients} from '#/alf/tokens'
+
+export function LinearGradientBackground({
+  style,
+  children,
+}: {
+  style: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  const gradient = gradients.sky.values.map(([_, color]) => {
+    return color
+  })
+
+  return (
+    <LinearGradient colors={gradient} style={style}>
+      {children}
+    </LinearGradient>
+  )
+}
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index 0354bfc43..6743a592b 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -9,11 +9,13 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {HITSLOP_10} from 'lib/constants'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {atoms as a} from '#/alf'
-import {Button} from '#/components/Button'
+import {isWeb} from 'platform/detection'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {useDialogControl} from '#/components/Dialog'
 import {Newskie} from '#/components/icons/Newskie'
+import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
 import {Text} from '#/components/Typography'
 
 export function NewskieDialog({
@@ -24,6 +26,7 @@ export function NewskieDialog({
   disabled?: boolean
 }) {
   const {_} = useLingui()
+  const t = useTheme()
   const moderationOpts = useModerationOpts()
   const control = useDialogControl()
   const profileName = React.useMemo(() => {
@@ -68,15 +71,62 @@ export function NewskieDialog({
           label={_(msg`New user info dialog`)}
           style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
           <View style={[a.gap_sm]}>
-            <Text style={[a.font_bold, a.text_xl]}>
-              <Trans>Say hello!</Trans>
-            </Text>
-            <Text style={[a.text_md]}>
-              <Trans>
-                {profileName} joined Bluesky{' '}
-                {timeAgo(createdAt, now, {format: 'long'})} ago
-              </Trans>
+            <View style={[a.align_center]}>
+              <Newskie
+                width={64}
+                height={64}
+                fill="#FFC404"
+                style={{marginTop: -10}}
+              />
+              <Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}>
+                <Trans>Say hello!</Trans>
+              </Text>
+            </View>
+            <Text style={[a.text_md, a.text_center, a.leading_tight]}>
+              {profile.joinedViaStarterPack ? (
+                <Trans>
+                  {profileName} joined Bluesky using a starter pack{' '}
+                  {timeAgo(createdAt, now, {format: 'long'})} ago
+                </Trans>
+              ) : (
+                <Trans>
+                  {profileName} joined Bluesky{' '}
+                  {timeAgo(createdAt, now, {format: 'long'})} ago
+                </Trans>
+              )}
             </Text>
+            {profile.joinedViaStarterPack ? (
+              <StarterPackCard.Link
+                starterPack={profile.joinedViaStarterPack}
+                onPress={() => {
+                  control.close()
+                }}>
+                <View
+                  style={[
+                    a.flex_1,
+                    a.mt_sm,
+                    a.p_lg,
+                    a.border,
+                    a.rounded_sm,
+                    t.atoms.border_contrast_low,
+                  ]}>
+                  <StarterPackCard.Card
+                    starterPack={profile.joinedViaStarterPack}
+                  />
+                </View>
+              </StarterPackCard.Link>
+            ) : null}
+            <Button
+              label={_(msg`Close`)}
+              variant="solid"
+              color="secondary"
+              size="small"
+              style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]}
+              onPress={() => control.close()}>
+              <ButtonText>
+                <Trans>Close</Trans>
+              </ButtonText>
+            </Button>
           </View>
         </Dialog.ScrollableInner>
       </Dialog.Outer>
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
new file mode 100644
index 000000000..a0d222854
--- /dev/null
+++ b/src/components/ProfileCard.tsx
@@ -0,0 +1,91 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+
+import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useSession} from 'state/session'
+import {FollowButton} from 'view/com/profile/FollowButton'
+import {ProfileCardPills} from 'view/com/profile/ProfileCard'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Default({
+  profile: profileUnshadowed,
+  moderationOpts,
+  logContext = 'ProfileCard',
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+  logContext?: 'ProfileCard' | 'StarterPackProfilesList'
+}) {
+  const t = useTheme()
+  const {currentAccount, hasSession} = useSession()
+
+  const profile = useProfileShadow(profileUnshadowed)
+  const name = createSanitizedDisplayName(profile)
+  const handle = `@${sanitizeHandle(profile.handle)}`
+  const moderation = moderateProfile(profile, moderationOpts)
+
+  return (
+    <Wrapper did={profile.did}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        <UserAvatar
+          size={42}
+          avatar={profile.avatar}
+          type={
+            profile.associated?.labeler
+              ? 'labeler'
+              : profile.associated?.feedgens
+              ? 'algo'
+              : 'user'
+          }
+          moderation={moderation.ui('avatar')}
+        />
+        <View style={[a.flex_1]}>
+          <Text
+            style={[a.text_md, a.font_bold, a.leading_snug]}
+            numberOfLines={1}>
+            {name}
+          </Text>
+          <Text
+            style={[a.leading_snug, t.atoms.text_contrast_medium]}
+            numberOfLines={1}>
+            {handle}
+          </Text>
+        </View>
+        {hasSession && profile.did !== currentAccount?.did && (
+          <View style={[a.justify_center, {marginLeft: 'auto'}]}>
+            <FollowButton profile={profile} logContext={logContext} />
+          </View>
+        )}
+      </View>
+      <View style={[a.mb_xs]}>
+        <ProfileCardPills
+          followedBy={Boolean(profile.viewer?.followedBy)}
+          moderation={moderation}
+        />
+      </View>
+      {profile.description && (
+        <Text numberOfLines={3} style={[a.leading_snug]}>
+          {profile.description}
+        </Text>
+      )}
+    </Wrapper>
+  )
+}
+
+function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
+  return (
+    <Link
+      to={{
+        screen: 'Profile',
+        params: {name: did},
+      }}>
+      <View style={[a.flex_1, a.gap_xs]}>{children}</View>
+    </Link>
+  )
+}
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
index 4413cbe89..169c07d73 100644
--- a/src/components/ReportDialog/SelectReportOptionView.tsx
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -55,6 +55,9 @@ export function SelectReportOptionView({
     } else if (props.params.type === 'feedgen') {
       title = _(msg`Report this feed`)
       description = _(msg`Why should this feed be reviewed?`)
+    } else if (props.params.type === 'starterpack') {
+      title = _(msg`Report this starter pack`)
+      description = _(msg`Why should this starter pack be reviewed?`)
     } else if (props.params.type === 'convoMessage') {
       title = _(msg`Report this message`)
       description = _(msg`Why should this message be reviewed?`)
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
index ceabe0b90..3f43db4a1 100644
--- a/src/components/ReportDialog/types.ts
+++ b/src/components/ReportDialog/types.ts
@@ -4,7 +4,7 @@ export type ReportDialogProps = {
   control: Dialog.DialogOuterProps['control']
   params:
     | {
-        type: 'post' | 'list' | 'feedgen' | 'other'
+        type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
         uri: string
         cid: string
       }
diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx
new file mode 100644
index 000000000..e350a422c
--- /dev/null
+++ b/src/components/StarterPack/Main/FeedsList.tsx
@@ -0,0 +1,68 @@
+import React, {useCallback} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {AppBskyFeedDefs} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {isNative, isWeb} from 'platform/detection'
+import {List, ListRef} from 'view/com/util/List'
+import {SectionRef} from '#/screens/Profile/Sections/types'
+import {atoms as a, useTheme} from '#/alf'
+import * as FeedCard from '#/components/FeedCard'
+
+function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
+  return item.uri
+}
+
+interface ProfilesListProps {
+  feeds: AppBskyFeedDefs.GeneratorView[]
+  headerHeight: number
+  scrollElRef: ListRef
+}
+
+export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
+  function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) {
+    const [initialHeaderHeight] = React.useState(headerHeight)
+    const bottomBarOffset = useBottomBarOffset(20)
+    const t = useTheme()
+
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({
+        animated: isNative,
+        offset: -headerHeight,
+      })
+    }, [scrollElRef, headerHeight])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => {
+      return (
+        <View
+          style={[
+            a.p_lg,
+            (isWeb || index !== 0) && a.border_t,
+            t.atoms.border_contrast_low,
+          ]}>
+          <FeedCard.Default type="feed" view={item} />
+        </View>
+      )
+    }
+
+    return (
+      <List
+        data={feeds}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        ref={scrollElRef}
+        headerOffset={headerHeight}
+        ListFooterComponent={
+          <View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
+        }
+        showsVerticalScrollIndicator={false}
+        desktopFixedHeight={true}
+      />
+    )
+  },
+)
diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
new file mode 100644
index 000000000..72d35fe2b
--- /dev/null
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -0,0 +1,119 @@
+import React, {useCallback} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphGetList,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {isNative, isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {List, ListRef} from 'view/com/util/List'
+import {SectionRef} from '#/screens/Profile/Sections/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Default as ProfileCard} from '#/components/ProfileCard'
+
+function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
+  return `${item.did}-${index}`
+}
+
+interface ProfilesListProps {
+  listUri: string
+  listMembersQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetList.OutputSchema>
+  >
+  moderationOpts: ModerationOpts
+  headerHeight: number
+  scrollElRef: ListRef
+}
+
+export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
+  function ProfilesListImpl(
+    {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
+    ref,
+  ) {
+    const t = useTheme()
+    const [initialHeaderHeight] = React.useState(headerHeight)
+    const bottomBarOffset = useBottomBarOffset(20)
+    const {currentAccount} = useSession()
+
+    const [isPTRing, setIsPTRing] = React.useState(false)
+
+    const {data, refetch} = listMembersQuery
+
+    // The server returns these sorted by descending creation date, so we want to invert
+    const profiles = data?.pages
+      .flatMap(p => p.items.map(i => i.subject))
+      .reverse()
+    const isOwn = new AtUri(listUri).host === currentAccount?.did
+
+    const getSortedProfiles = () => {
+      if (!profiles) return
+      if (!isOwn) return profiles
+
+      const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
+      return myIndex !== -1
+        ? [
+            profiles[myIndex],
+            ...profiles.slice(0, myIndex),
+            ...profiles.slice(myIndex + 1),
+          ]
+        : profiles
+    }
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({
+        animated: isNative,
+        offset: -headerHeight,
+      })
+    }, [scrollElRef, headerHeight])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderItem = ({
+      item,
+      index,
+    }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
+      return (
+        <View
+          style={[
+            a.p_lg,
+            t.atoms.border_contrast_low,
+            (isWeb || index !== 0) && a.border_t,
+          ]}>
+          <ProfileCard
+            profile={item}
+            moderationOpts={moderationOpts}
+            logContext="StarterPackProfilesList"
+          />
+        </View>
+      )
+    }
+
+    if (listMembersQuery)
+      return (
+        <List
+          data={getSortedProfiles()}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          ref={scrollElRef}
+          headerOffset={headerHeight}
+          ListFooterComponent={
+            <View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
+          }
+          showsVerticalScrollIndicator={false}
+          desktopFixedHeight
+          refreshing={isPTRing}
+          onRefresh={async () => {
+            setIsPTRing(true)
+            await refetch()
+            setIsPTRing(false)
+          }}
+        />
+      )
+  },
+)
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
new file mode 100644
index 000000000..096f04f2d
--- /dev/null
+++ b/src/components/StarterPack/ProfileStarterPacks.tsx
@@ -0,0 +1,320 @@
+import React from 'react'
+import {
+  findNodeHandle,
+  ListRenderItemInfo,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {NavigationProp} from 'lib/routes/types'
+import {parseStarterPackUri} from 'lib/strings/starter-pack'
+import {List, ListRef} from 'view/com/util/List'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
+
+interface SectionRef {
+  scrollToTop: () => void
+}
+
+interface ProfileFeedgensProps {
+  starterPacksQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
+    Error
+  >
+  scrollElRef: ListRef
+  headerOffset: number
+  enabled?: boolean
+  style?: StyleProp<ViewStyle>
+  testID?: string
+  setScrollViewTag: (tag: number | null) => void
+  isMe: boolean
+}
+
+function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
+  return item.uri
+}
+
+export const ProfileStarterPacks = React.forwardRef<
+  SectionRef,
+  ProfileFeedgensProps
+>(function ProfileFeedgensImpl(
+  {
+    starterPacksQuery: query,
+    scrollElRef,
+    headerOffset,
+    enabled,
+    style,
+    testID,
+    setScrollViewTag,
+    isMe,
+  },
+  ref,
+) {
+  const t = useTheme()
+  const bottomBarOffset = useBottomBarOffset(100)
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
+  const {isTabletOrDesktop} = useWebMediaQueries()
+
+  const items = data?.pages.flatMap(page => page.starterPacks)
+
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: () => {},
+  }))
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh starter packs', {message: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage) return
+
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more starter packs', {message: err})
+    }
+  }, [isFetching, hasNextPage, fetchNextPage])
+
+  React.useEffect(() => {
+    if (enabled && scrollElRef.current) {
+      const nativeTag = findNodeHandle(scrollElRef.current)
+      setScrollViewTag(nativeTag)
+    }
+  }, [enabled, scrollElRef, setScrollViewTag])
+
+  const renderItem = ({
+    item,
+    index,
+  }: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
+    return (
+      <View
+        style={[
+          a.p_lg,
+          (isTabletOrDesktop || index !== 0) && a.border_t,
+          t.atoms.border_contrast_low,
+        ]}>
+        <StarterPackCard starterPack={item} />
+      </View>
+    )
+  }
+
+  return (
+    <View testID={testID} style={style}>
+      <List
+        testID={testID ? `${testID}-flatlist` : undefined}
+        ref={scrollElRef}
+        data={items}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        refreshing={isPTRing}
+        headerOffset={headerOffset}
+        contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
+        indicatorStyle={t.name === 'light' ? 'black' : 'white'}
+        removeClippedSubviews={true}
+        desktopFixedHeight
+        onEndReached={onEndReached}
+        onRefresh={onRefresh}
+        ListEmptyComponent={Empty}
+        ListFooterComponent={
+          items?.length !== 0 && isMe ? CreateAnother : undefined
+        }
+      />
+    </View>
+  )
+})
+
+function CreateAnother() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+
+  return (
+    <View
+      style={[
+        a.pr_md,
+        a.pt_lg,
+        a.gap_lg,
+        a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <Button
+        label={_(msg`Create a starter pack`)}
+        variant="solid"
+        color="secondary"
+        size="small"
+        style={[a.self_center]}
+        onPress={() => navigation.navigate('StarterPackWizard')}>
+        <ButtonText>
+          <Trans>Create another</Trans>
+        </ButtonText>
+        <ButtonIcon icon={Plus} position="right" />
+      </Button>
+    </View>
+  )
+}
+
+function Empty() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const confirmDialogControl = useDialogControl()
+  const followersDialogControl = useDialogControl()
+  const errorDialogControl = useDialogControl()
+
+  const [isGenerating, setIsGenerating] = React.useState(false)
+
+  const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
+    onSuccess: ({uri}) => {
+      const parsed = parseStarterPackUri(uri)
+      if (parsed) {
+        navigation.push('StarterPack', {
+          name: parsed.name,
+          rkey: parsed.rkey,
+        })
+      }
+      setIsGenerating(false)
+    },
+    onError: e => {
+      logger.error('Failed to generate starter pack', {safeMessage: e})
+      setIsGenerating(false)
+      if (e.name === 'NOT_ENOUGH_FOLLOWERS') {
+        followersDialogControl.open()
+      } else {
+        errorDialogControl.open()
+      }
+    },
+  })
+
+  const generate = () => {
+    setIsGenerating(true)
+    generateStarterPack()
+  }
+
+  return (
+    <LinearGradientBackground
+      style={[
+        a.px_lg,
+        a.py_lg,
+        a.justify_between,
+        a.gap_lg,
+        a.shadow_lg,
+        {marginTop: 2},
+      ]}>
+      <View style={[a.gap_xs]}>
+        <Text
+          style={[
+            a.font_bold,
+            a.text_lg,
+            t.atoms.text_contrast_medium,
+            {color: 'white'},
+          ]}>
+          You haven't created a starter pack yet!
+        </Text>
+        <Text style={[a.text_md, {color: 'white'}]}>
+          Starter packs let you easily share your favorite feeds and people with
+          your friends.
+        </Text>
+      </View>
+      <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
+        <Button
+          label={_(msg`Create a starter pack for me`)}
+          variant="ghost"
+          color="primary"
+          size="small"
+          disabled={isGenerating}
+          onPress={confirmDialogControl.open}
+          style={{backgroundColor: 'transparent'}}>
+          <ButtonText style={{color: 'white'}}>
+            <Trans>Make one for me</Trans>
+          </ButtonText>
+          {isGenerating && <Loader size="md" />}
+        </Button>
+        <Button
+          label={_(msg`Create a starter pack`)}
+          variant="ghost"
+          color="primary"
+          size="small"
+          disabled={isGenerating}
+          onPress={() => navigation.navigate('StarterPackWizard')}
+          style={{
+            backgroundColor: 'white',
+            borderColor: 'white',
+            width: 100,
+          }}
+          hoverStyle={[{backgroundColor: '#dfdfdf'}]}>
+          <ButtonText>
+            <Trans>Create</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+
+      <Prompt.Outer control={confirmDialogControl}>
+        <Prompt.TitleText>
+          <Trans>Generate a starter pack</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>
+            Bluesky will choose a set of recommended accounts from people in
+            your network.
+          </Trans>
+        </Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            color="primary"
+            cta={_(msg`Choose for me`)}
+            onPress={generate}
+          />
+          <Prompt.Action
+            color="secondary"
+            cta={_(msg`Let me choose`)}
+            onPress={() => {
+              navigation.navigate('StarterPackWizard')
+            }}
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+      <Prompt.Basic
+        control={followersDialogControl}
+        title={_(msg`Oops!`)}
+        description={_(
+          msg`You must be following at least seven other people to generate a starter pack.`,
+        )}
+        onConfirm={() => {}}
+        showCancel={false}
+      />
+      <Prompt.Basic
+        control={errorDialogControl}
+        title={_(msg`Oops!`)}
+        description={_(
+          msg`An error occurred while generating your starter pack. Want to try again?`,
+        )}
+        onConfirm={generate}
+        confirmButtonCta={_(msg`Retry`)}
+      />
+    </LinearGradientBackground>
+  )
+}
diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx
new file mode 100644
index 000000000..08ee03d62
--- /dev/null
+++ b/src/components/StarterPack/QrCode.tsx
@@ -0,0 +1,119 @@
+import React from 'react'
+import {View} from 'react-native'
+import QRCode from 'react-native-qrcode-styled'
+import ViewShot from 'react-native-view-shot'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from 'platform/detection'
+import {Logo} from 'view/icons/Logo'
+import {Logotype} from 'view/icons/Logotype'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {Text} from '#/components/Typography'
+
+interface Props {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  link: string
+}
+
+export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
+  {starterPack, link},
+  ref,
+) {
+  const {record} = starterPack
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <ViewShot ref={ref}>
+      <LinearGradientBackground
+        style={[
+          {width: 300, minHeight: 390},
+          a.align_center,
+          a.px_sm,
+          a.py_xl,
+          a.rounded_sm,
+          a.justify_between,
+          a.gap_md,
+        ]}>
+        <View style={[a.gap_sm]}>
+          <Text
+            style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}>
+            {record.name}
+          </Text>
+        </View>
+        <View style={[a.gap_xl, a.align_center]}>
+          <Text
+            style={[
+              a.font_bold,
+              a.text_center,
+              {color: 'white', fontSize: 18},
+            ]}>
+            <Trans>Join the conversation</Trans>
+          </Text>
+          <View style={[a.rounded_sm, a.overflow_hidden]}>
+            <QrCodeInner link={link} />
+          </View>
+
+          <View style={[a.flex_row, a.align_center, {gap: 5}]}>
+            <Text
+              style={[
+                a.font_bold,
+                a.text_center,
+                {color: 'white', fontSize: 18},
+              ]}>
+              <Trans>on</Trans>
+            </Text>
+            <Logo width={26} fill="white" />
+            <View style={[{marginTop: 5, marginLeft: 2.5}]}>
+              <Logotype width={68} fill="white" />
+            </View>
+          </View>
+        </View>
+      </LinearGradientBackground>
+    </ViewShot>
+  )
+})
+
+export function QrCodeInner({link}: {link: string}) {
+  const t = useTheme()
+
+  return (
+    <QRCode
+      data={link}
+      style={[
+        a.rounded_sm,
+        {height: 225, width: 225, backgroundColor: '#f3f3f3'},
+      ]}
+      pieceSize={isWeb ? 8 : 6}
+      padding={20}
+      // pieceLiquidRadius={2}
+      pieceBorderRadius={isWeb ? 4.5 : 3.5}
+      outerEyesOptions={{
+        topLeft: {
+          borderRadius: [12, 12, 0, 12],
+          color: t.palette.primary_500,
+        },
+        topRight: {
+          borderRadius: [12, 12, 12, 0],
+          color: t.palette.primary_500,
+        },
+        bottomLeft: {
+          borderRadius: [12, 0, 12, 12],
+          color: t.palette.primary_500,
+        },
+      }}
+      innerEyesOptions={{borderRadius: 3}}
+      logo={{
+        href: require('../../../assets/logo.png'),
+        scale: 1.2,
+        padding: 2,
+        hidePieces: true,
+      }}
+    />
+  )
+}
diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx
new file mode 100644
index 000000000..580c6cc7c
--- /dev/null
+++ b/src/components/StarterPack/QrCodeDialog.tsx
@@ -0,0 +1,201 @@
+import React from 'react'
+import {View} from 'react-native'
+import ViewShot from 'react-native-view-shot'
+import * as FS from 'expo-file-system'
+import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
+import * as Sharing from 'expo-sharing'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {saveImageToMediaLibrary} from 'lib/media/manip'
+import {logEvent} from 'lib/statsig/statsig'
+import {isNative, isWeb} from 'platform/detection'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {QrCode} from '#/components/StarterPack/QrCode'
+
+export function QrCodeDialog({
+  starterPack,
+  link,
+  control,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  link?: string
+  control: DialogControlProps
+}) {
+  const {_} = useLingui()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+
+  const ref = React.useRef<ViewShot>(null)
+
+  const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
+    return new Promise(resolve => {
+      const image = new Image()
+      image.onload = () => {
+        const canvas = document.createElement('canvas')
+        canvas.width = image.width
+        canvas.height = image.height
+
+        const ctx = canvas.getContext('2d')
+        ctx?.drawImage(image, 0, 0)
+        resolve(canvas)
+      }
+      image.src = base64
+    })
+  }
+
+  const onSavePress = async () => {
+    ref.current?.capture?.().then(async (uri: string) => {
+      if (isNative) {
+        const res = await requestMediaLibraryPermissionsAsync()
+
+        if (!res) {
+          Toast.show(
+            _(
+              msg`You must grant access to your photo library to save a QR code`,
+            ),
+          )
+          return
+        }
+
+        const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
+
+        // Incase of a FS failure, don't crash the app
+        try {
+          await FS.copyAsync({from: uri, to: filename})
+          await saveImageToMediaLibrary({uri: filename})
+          await FS.deleteAsync(filename)
+        } catch (e: unknown) {
+          Toast.show(_(msg`An error occurred while saving the QR code!`))
+          logger.error('Failed to save QR code', {error: e})
+          return
+        }
+      } else {
+        setIsProcessing(true)
+
+        if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+          return
+        }
+
+        const canvas = await getCanvas(uri)
+        const imgHref = canvas
+          .toDataURL('image/png')
+          .replace('image/png', 'image/octet-stream')
+
+        const link = document.createElement('a')
+        link.setAttribute(
+          'download',
+          `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`,
+        )
+        link.setAttribute('href', imgHref)
+        link.click()
+      }
+
+      logEvent('starterPack:share', {
+        starterPack: starterPack.uri,
+        shareType: 'qrcode',
+        qrShareType: 'save',
+      })
+      setIsProcessing(false)
+      Toast.show(
+        isWeb
+          ? _(msg`QR code has been downloaded!`)
+          : _(msg`QR code saved to your camera roll!`),
+      )
+      control.close()
+    })
+  }
+
+  const onCopyPress = async () => {
+    setIsProcessing(true)
+    ref.current?.capture?.().then(async (uri: string) => {
+      const canvas = await getCanvas(uri)
+      // @ts-expect-error web only
+      canvas.toBlob((blob: Blob) => {
+        const item = new ClipboardItem({'image/png': blob})
+        navigator.clipboard.write([item])
+      })
+
+      logEvent('starterPack:share', {
+        starterPack: starterPack.uri,
+        shareType: 'qrcode',
+        qrShareType: 'copy',
+      })
+      Toast.show(_(msg`QR code copied to your clipboard!`))
+      setIsProcessing(false)
+      control.close()
+    })
+  }
+
+  const onSharePress = async () => {
+    ref.current?.capture?.().then(async (uri: string) => {
+      control.close(() => {
+        Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
+          () => {
+            logEvent('starterPack:share', {
+              starterPack: starterPack.uri,
+              shareType: 'qrcode',
+              qrShareType: 'share',
+            })
+          },
+        )
+      })
+    })
+  }
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner
+        label={_(msg`Create a QR code for a starter pack`)}>
+        <View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+          {!link ? (
+            <View style={[a.align_center, a.p_xl]}>
+              <Loader size="xl" />
+            </View>
+          ) : (
+            <>
+              <QrCode starterPack={starterPack} link={link} ref={ref} />
+              {isProcessing ? (
+                <View>
+                  <Loader size="xl" />
+                </View>
+              ) : (
+                <View
+                  style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}>
+                  <Button
+                    label={_(msg`Copy QR code`)}
+                    variant="solid"
+                    color="secondary"
+                    size="small"
+                    onPress={isWeb ? onCopyPress : onSharePress}>
+                    <ButtonText>
+                      {isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
+                    </ButtonText>
+                  </Button>
+                  <Button
+                    label={_(msg`Save QR code`)}
+                    variant="solid"
+                    color="secondary"
+                    size="small"
+                    onPress={onSavePress}>
+                    <ButtonText>
+                      <Trans>Save</Trans>
+                    </ButtonText>
+                  </Button>
+                </View>
+              )}
+            </>
+          )}
+        </View>
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx
new file mode 100644
index 000000000..23fa10fb3
--- /dev/null
+++ b/src/components/StarterPack/ShareDialog.tsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import {View} from 'react-native'
+import * as FS from 'expo-file-system'
+import {Image} from 'expo-image'
+import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
+import {AppBskyGraphDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {saveImageToMediaLibrary} from 'lib/media/manip'
+import {shareUrl} from 'lib/sharing'
+import {logEvent} from 'lib/statsig/statsig'
+import {getStarterPackOgCard} from 'lib/strings/starter-pack'
+import {isNative, isWeb} from 'platform/detection'
+import * as Toast from 'view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {DialogControlProps} from '#/components/Dialog'
+import * as Dialog from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+interface Props {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  link?: string
+  imageLoaded?: boolean
+  qrDialogControl: DialogControlProps
+  control: DialogControlProps
+}
+
+export function ShareDialog(props: Props) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <ShareDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ShareDialogInner({
+  starterPack,
+  link,
+  imageLoaded,
+  qrDialogControl,
+  control,
+}: Props) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+
+  const imageUrl = getStarterPackOgCard(starterPack)
+
+  const onShareLink = async () => {
+    if (!link) return
+    shareUrl(link)
+    logEvent('starterPack:share', {
+      starterPack: starterPack.uri,
+      shareType: 'link',
+    })
+    control.close()
+  }
+
+  const onSave = async () => {
+    const res = await requestMediaLibraryPermissionsAsync()
+
+    if (!res) {
+      Toast.show(
+        _(msg`You must grant access to your photo library to save the image.`),
+      )
+      return
+    }
+
+    const cachePath = await Image.getCachePathAsync(imageUrl)
+    const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
+
+    if (!cachePath) {
+      Toast.show(_(msg`An error occurred while saving the image.`))
+      return
+    }
+
+    try {
+      await FS.copyAsync({from: cachePath, to: filename})
+      await saveImageToMediaLibrary({uri: filename})
+      await FS.deleteAsync(filename)
+
+      Toast.show(_(msg`Image saved to your camera roll!`))
+      control.close()
+    } catch (e: unknown) {
+      Toast.show(_(msg`An error occurred while saving the QR code!`))
+      logger.error('Failed to save QR code', {error: e})
+      return
+    }
+  }
+
+  return (
+    <>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
+        {!imageLoaded || !link ? (
+          <View style={[a.p_xl, a.align_center]}>
+            <Loader size="xl" />
+          </View>
+        ) : (
+          <View style={[!isTabletOrDesktop && a.gap_lg]}>
+            <View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}>
+              <Text style={[a.font_bold, a.text_2xl]}>
+                <Trans>Invite people to this starter pack!</Trans>
+              </Text>
+              <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+                <Trans>
+                  Share this starter pack and help people join your community on
+                  Bluesky.
+                </Trans>
+              </Text>
+            </View>
+            <Image
+              source={{uri: imageUrl}}
+              style={[
+                a.rounded_sm,
+                {
+                  aspectRatio: 1200 / 630,
+                  transform: [{scale: isTabletOrDesktop ? 0.85 : 1}],
+                  marginTop: isTabletOrDesktop ? -20 : 0,
+                },
+              ]}
+              accessibilityIgnoresInvertColors={true}
+            />
+            <View
+              style={[
+                a.gap_md,
+                isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}],
+              ]}>
+              <Button
+                label="Share link"
+                variant="solid"
+                color="secondary"
+                size="small"
+                style={[isWeb && a.self_center]}
+                onPress={onShareLink}>
+                <ButtonText>
+                  {isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>}
+                </ButtonText>
+              </Button>
+              <Button
+                label="Create QR code"
+                variant="solid"
+                color="secondary"
+                size="small"
+                style={[isWeb && a.self_center]}
+                onPress={() => {
+                  control.close(() => {
+                    qrDialogControl.open()
+                  })
+                }}>
+                <ButtonText>
+                  <Trans>Create QR code</Trans>
+                </ButtonText>
+              </Button>
+              {isNative && (
+                <Button
+                  label={_(msg`Save image`)}
+                  variant="ghost"
+                  color="secondary"
+                  size="small"
+                  style={[isWeb && a.self_center]}
+                  onPress={onSave}>
+                  <ButtonText>
+                    <Trans>Save image</Trans>
+                  </ButtonText>
+                </Button>
+              )}
+            </View>
+          </View>
+        )}
+      </Dialog.ScrollableInner>
+    </>
+  )
+}
diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx
new file mode 100644
index 000000000..ab904d7ff
--- /dev/null
+++ b/src/components/StarterPack/StarterPackCard.tsx
@@ -0,0 +1,117 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
+import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
+  if (!starterPack) return null
+  return (
+    <Link starterPack={starterPack}>
+      <Card starterPack={starterPack} />
+    </Link>
+  )
+}
+
+export function Notification({
+  starterPack,
+}: {
+  starterPack?: StarterPackViewBasic
+}) {
+  if (!starterPack) return null
+  return (
+    <Link starterPack={starterPack}>
+      <Card starterPack={starterPack} noIcon={true} noDescription={true} />
+    </Link>
+  )
+}
+
+export function Card({
+  starterPack,
+  noIcon,
+  noDescription,
+}: {
+  starterPack: StarterPackViewBasic
+  noIcon?: boolean
+  noDescription?: boolean
+}) {
+  const {record, creator, joinedAllTimeCount} = starterPack
+
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_1, a.gap_md]}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        {!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
+        <View>
+          <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+            {record.name}
+          </Text>
+          <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Starter pack by{' '}
+              {creator?.did === currentAccount?.did
+                ? _(msg`you`)
+                : `@${sanitizeHandle(creator.handle)}`}
+            </Trans>
+          </Text>
+        </View>
+      </View>
+      {!noDescription && record.description ? (
+        <Text numberOfLines={3} style={[a.leading_snug]}>
+          {record.description}
+        </Text>
+      ) : null}
+      {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
+        <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+          {joinedAllTimeCount} users have joined!
+        </Text>
+      )}
+    </View>
+  )
+}
+
+export function Link({
+  starterPack,
+  children,
+  ...rest
+}: {
+  starterPack: StarterPackViewBasic
+} & Omit<LinkProps, 'to'>) {
+  const {record} = starterPack
+  const {rkey, handleOrDid} = React.useMemo(() => {
+    const rkey = new AtUri(starterPack.uri).rkey
+    const {creator} = starterPack
+    return {rkey, handleOrDid: creator.handle || creator.did}
+  }, [starterPack])
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <InternalLink
+      label={record.name}
+      {...rest}
+      to={{
+        screen: 'StarterPack',
+        params: {name: handleOrDid, rkey},
+      }}>
+      {children}
+    </InternalLink>
+  )
+}
diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx
new file mode 100644
index 000000000..b7cd4e4c1
--- /dev/null
+++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  SlideInLeft,
+  SlideInRight,
+} from 'react-native-reanimated'
+
+import {isWeb} from 'platform/detection'
+
+export function ScreenTransition({
+  direction,
+  style,
+  children,
+}: {
+  direction: 'Backward' | 'Forward'
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  const entering = direction === 'Forward' ? SlideInRight : SlideInLeft
+
+  return (
+    <Animated.View
+      entering={isWeb ? FadeIn.duration(90) : entering}
+      exiting={FadeOut.duration(90)} // Totally vibes based
+      style={style}>
+      {children}
+    </Animated.View>
+  )
+}
diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
new file mode 100644
index 000000000..bf250ac35
--- /dev/null
+++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
@@ -0,0 +1,152 @@
+import React, {useRef} from 'react'
+import type {ListRenderItemInfo} from 'react-native'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, native, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {
+  WizardFeedCard,
+  WizardProfileCard,
+} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(
+  item: AppBskyActorDefs.ProfileViewBasic | GeneratorView,
+  index: number,
+) {
+  return `${item.did}-${index}`
+}
+
+export function WizardEditListDialog({
+  control,
+  state,
+  dispatch,
+  moderationOpts,
+  profile,
+}: {
+  control: Dialog.DialogControlProps
+  state: WizardState
+  dispatch: (action: WizardAction) => void
+  moderationOpts: ModerationOpts
+  profile: AppBskyActorDefs.ProfileViewBasic
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+
+  const listRef = useRef<BottomSheetFlatListMethods>(null)
+
+  const getData = () => {
+    if (state.currentStep === 'Feeds') return state.feeds
+
+    return [
+      profile,
+      ...state.profiles.filter(p => p.did !== currentAccount?.did),
+    ]
+  }
+
+  const renderItem = ({item}: ListRenderItemInfo<any>) =>
+    state.currentStep === 'Profiles' ? (
+      <WizardProfileCard
+        profile={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    ) : (
+      <WizardFeedCard
+        generator={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    )
+
+  return (
+    <Dialog.Outer
+      control={control}
+      testID="newChatDialog"
+      nativeOptions={{sheet: {snapPoints: ['95%']}}}>
+      <Dialog.Handle />
+      <Dialog.InnerFlatList
+        ref={listRef}
+        data={getData()}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        ListHeaderComponent={
+          <View
+            style={[
+              a.flex_row,
+              a.justify_between,
+              a.border_b,
+              a.px_sm,
+              a.mb_sm,
+              t.atoms.bg,
+              t.atoms.border_contrast_medium,
+              isWeb
+                ? [
+                    a.align_center,
+                    {
+                      height: 48,
+                    },
+                  ]
+                : [
+                    a.pb_sm,
+                    a.align_end,
+                    {
+                      height: 68,
+                    },
+                  ],
+            ]}>
+            <View style={{width: 60}} />
+            <Text style={[a.font_bold, a.text_xl]}>
+              {state.currentStep === 'Profiles' ? (
+                <Trans>Edit People</Trans>
+              ) : (
+                <Trans>Edit Feeds</Trans>
+              )}
+            </Text>
+            <View style={{width: 60}}>
+              {isWeb && (
+                <Button
+                  label={_(msg`Close`)}
+                  variant="ghost"
+                  color="primary"
+                  size="xsmall"
+                  onPress={() => control.close()}>
+                  <ButtonText>
+                    <Trans>Close</Trans>
+                  </ButtonText>
+                </Button>
+              )}
+            </View>
+          </View>
+        }
+        stickyHeaderIndices={[0]}
+        style={[
+          web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
+          native({
+            height: '100%',
+            paddingHorizontal: 0,
+            marginTop: 0,
+            paddingTop: 0,
+            borderTopLeftRadius: 40,
+            borderTopRightRadius: 40,
+          }),
+        ]}
+        webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+        keyboardDismissMode="on-drag"
+        removeClippedSubviews={true}
+      />
+    </Dialog.Outer>
+  )
+}
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
new file mode 100644
index 000000000..f1332011d
--- /dev/null
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {Keyboard, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  moderateFeedGenerator,
+  moderateProfile,
+  ModerationOpts,
+  ModerationUI,
+} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {DISCOVER_FEED_URI} from 'lib/constants'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Checkbox} from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+function WizardListCard({
+  type,
+  displayName,
+  subtitle,
+  onPress,
+  avatar,
+  included,
+  disabled,
+  moderationUi,
+}: {
+  type: 'user' | 'algo'
+  profile?: AppBskyActorDefs.ProfileViewBasic
+  feed?: AppBskyFeedDefs.GeneratorView
+  displayName: string
+  subtitle: string
+  onPress: () => void
+  avatar?: string
+  included?: boolean
+  disabled?: boolean
+  moderationUi: ModerationUI
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Toggle.Item
+      name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)}
+      label={
+        included
+          ? _(msg`Remove ${displayName} from starter pack`)
+          : _(msg`Add ${displayName} to starter pack`)
+      }
+      value={included}
+      disabled={disabled}
+      onChange={onPress}
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.px_lg,
+        a.py_md,
+        a.gap_md,
+        a.border_b,
+        t.atoms.border_contrast_low,
+      ]}>
+      <UserAvatar
+        size={45}
+        avatar={avatar}
+        moderation={moderationUi}
+        type={type}
+      />
+      <View style={[a.flex_1, a.gap_2xs]}>
+        <Text
+          style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]}
+          numberOfLines={1}>
+          {displayName}
+        </Text>
+        <Text
+          style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]}
+          numberOfLines={1}>
+          {subtitle}
+        </Text>
+      </View>
+      <Checkbox />
+    </Toggle.Item>
+  )
+}
+
+export function WizardProfileCard({
+  state,
+  dispatch,
+  profile,
+  moderationOpts,
+}: {
+  state: WizardState
+  dispatch: (action: WizardAction) => void
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderationOpts: ModerationOpts
+}) {
+  const {currentAccount} = useSession()
+
+  const isMe = profile.did === currentAccount?.did
+  const included = isMe || state.profiles.some(p => p.did === profile.did)
+  const disabled = isMe || (!included && state.profiles.length >= 49)
+  const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
+  const displayName = profile.displayName
+    ? sanitizeDisplayName(profile.displayName)
+    : `@${sanitizeHandle(profile.handle)}`
+
+  const onPress = () => {
+    if (disabled) return
+
+    Keyboard.dismiss()
+    if (profile.did === currentAccount?.did) return
+
+    if (!included) {
+      dispatch({type: 'AddProfile', profile})
+    } else {
+      dispatch({type: 'RemoveProfile', profileDid: profile.did})
+    }
+  }
+
+  return (
+    <WizardListCard
+      type="user"
+      displayName={displayName}
+      subtitle={`@${sanitizeHandle(profile.handle)}`}
+      onPress={onPress}
+      avatar={profile.avatar}
+      included={included}
+      disabled={disabled}
+      moderationUi={moderationUi}
+    />
+  )
+}
+
+export function WizardFeedCard({
+  generator,
+  state,
+  dispatch,
+  moderationOpts,
+}: {
+  generator: GeneratorView
+  state: WizardState
+  dispatch: (action: WizardAction) => void
+  moderationOpts: ModerationOpts
+}) {
+  const isDiscover = generator.uri === DISCOVER_FEED_URI
+  const included = isDiscover || state.feeds.some(f => f.uri === generator.uri)
+  const disabled = isDiscover || (!included && state.feeds.length >= 3)
+  const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui(
+    'avatar',
+  )
+
+  const onPress = () => {
+    if (disabled) return
+
+    Keyboard.dismiss()
+    if (included) {
+      dispatch({type: 'RemoveFeed', feedUri: generator.uri})
+    } else {
+      dispatch({type: 'AddFeed', feed: generator})
+    }
+  }
+
+  return (
+    <WizardListCard
+      type="algo"
+      displayName={sanitizeDisplayName(generator.displayName)}
+      subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
+      onPress={onPress}
+      avatar={generator.avatar}
+      included={included}
+      disabled={disabled}
+      moderationUi={moderationUi}
+    />
+  )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index f7a827b49..d513a6db9 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) {
     onChangeText,
     isInvalid,
     inputRef,
+    style,
     ...rest
   }: InputProps) {
     const t = useTheme()
@@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) {
             android({
               paddingBottom: 16,
             }),
+            style,
           ]}
         />
 
diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts
new file mode 100644
index 000000000..b6e4ab05b
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.native.ts
@@ -0,0 +1,68 @@
+import React from 'react'
+
+import {
+  createStarterPackLinkFromAndroidReferrer,
+  httpStarterPackUriToAtUri,
+} from 'lib/strings/starter-pack'
+import {isAndroid} from 'platform/detection'
+import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
+import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
+
+export function useStarterPackEntry() {
+  const [ready, setReady] = React.useState(false)
+  const setActiveStarterPack = useSetActiveStarterPack()
+  const hasCheckedForStarterPack = useHasCheckedForStarterPack()
+
+  React.useEffect(() => {
+    if (ready) return
+
+    // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
+    // let's just ensure we never check again after the first time.
+    if (hasCheckedForStarterPack) {
+      setReady(true)
+      return
+    }
+
+    // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate
+    const timeout = setTimeout(() => {
+      setReady(true)
+    }, 500)
+
+    ;(async () => {
+      let uri: string | null | undefined
+
+      if (isAndroid) {
+        const res = await Referrer.getGooglePlayReferrerInfoAsync()
+
+        if (res && res.installReferrer) {
+          uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
+        }
+      } else {
+        const res = await DevicePrefs.getStringValueAsync(
+          'starterPackUri',
+          true,
+        )
+
+        if (res) {
+          uri = httpStarterPackUriToAtUri(res)
+          DevicePrefs.setStringValueAsync('starterPackUri', null, true)
+        }
+      }
+
+      if (uri) {
+        setActiveStarterPack({
+          uri,
+        })
+      }
+
+      setReady(true)
+    })()
+
+    return () => {
+      clearTimeout(timeout)
+    }
+  }, [ready, setActiveStarterPack, hasCheckedForStarterPack])
+
+  return ready
+}
diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts
new file mode 100644
index 000000000..dba801e09
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.ts
@@ -0,0 +1,29 @@
+import React from 'react'
+
+import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack'
+import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+
+export function useStarterPackEntry() {
+  const [ready, setReady] = React.useState(false)
+
+  const setActiveStarterPack = useSetActiveStarterPack()
+
+  React.useEffect(() => {
+    const href = window.location.href
+    const atUri = httpStarterPackUriToAtUri(href)
+
+    if (atUri) {
+      const url = new URL(href)
+      // Determines if an App Clip is loading this landing page
+      const isClip = url.searchParams.get('clip') === 'true'
+      setActiveStarterPack({
+        uri: atUri,
+        isClip,
+      })
+    }
+
+    setReady(true)
+  }, [setActiveStarterPack])
+
+  return ready
+}
diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx
new file mode 100644
index 000000000..e841071f7
--- /dev/null
+++ b/src/components/icons/QrCode.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z',
+})
diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx
new file mode 100644
index 000000000..8c678bca4
--- /dev/null
+++ b/src/components/icons/StarterPack.tsx
@@ -0,0 +1,8 @@
+import {createMultiPathSVG} from './TEMPLATE'
+
+export const StarterPack = createMultiPathSVG({
+  paths: [
+    'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z',
+    'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z',
+  ],
+})
diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx
index f49c4280b..47a5c36b2 100644
--- a/src/components/icons/TEMPLATE.tsx
+++ b/src/components/icons/TEMPLATE.tsx
@@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
 
 export function createSinglePathSVG({path}: {path: string}) {
   return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
-    const {fill, size, style, ...rest} = useCommonSVGProps(props)
+    const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
 
     return (
       <Svg
@@ -41,8 +41,37 @@ export function createSinglePathSVG({path}: {path: string}) {
         width={size}
         height={size}
         style={[style]}>
+        {gradient}
         <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
       </Svg>
     )
   })
 }
+
+export function createMultiPathSVG({paths}: {paths: string[]}) {
+  return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
+    const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
+
+    return (
+      <Svg
+        fill="none"
+        {...rest}
+        ref={ref}
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        style={[style]}>
+        {gradient}
+        {paths.map((path, i) => (
+          <Path
+            key={i}
+            fill={fill}
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d={path}
+          />
+        ))}
+      </Svg>
+    )
+  })
+}
diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts
deleted file mode 100644
index 669c157f5..000000000
--- a/src/components/icons/common.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import {StyleSheet, TextProps} from 'react-native'
-import type {PathProps, SvgProps} from 'react-native-svg'
-
-import {tokens} from '#/alf'
-
-export type Props = {
-  fill?: PathProps['fill']
-  style?: TextProps['style']
-  size?: keyof typeof sizes
-} & Omit<SvgProps, 'style' | 'size'>
-
-export const sizes = {
-  xs: 12,
-  sm: 16,
-  md: 20,
-  lg: 24,
-  xl: 28,
-}
-
-export function useCommonSVGProps(props: Props) {
-  const {fill, size, ...rest} = props
-  const style = StyleSheet.flatten(rest.style)
-  const _fill = fill || style?.color || tokens.color.blue_500
-  const _size = Number(size ? sizes[size] : rest.width || sizes.md)
-
-  return {
-    fill: _fill,
-    size: _size,
-    style,
-    ...rest,
-  }
-}
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
new file mode 100644
index 000000000..662718338
--- /dev/null
+++ b/src/components/icons/common.tsx
@@ -0,0 +1,59 @@
+import React from 'react'
+import {StyleSheet, TextProps} from 'react-native'
+import type {PathProps, SvgProps} from 'react-native-svg'
+import {Defs, LinearGradient, Stop} from 'react-native-svg'
+import {nanoid} from 'nanoid/non-secure'
+
+import {tokens} from '#/alf'
+
+export type Props = {
+  fill?: PathProps['fill']
+  style?: TextProps['style']
+  size?: keyof typeof sizes
+  gradient?: keyof typeof tokens.gradients
+} & Omit<SvgProps, 'style' | 'size'>
+
+export const sizes = {
+  xs: 12,
+  sm: 16,
+  md: 20,
+  lg: 24,
+  xl: 28,
+}
+
+export function useCommonSVGProps(props: Props) {
+  const {fill, size, gradient, ...rest} = props
+  const style = StyleSheet.flatten(rest.style)
+  const _size = Number(size ? sizes[size] : rest.width || sizes.md)
+  let _fill = fill || style?.color || tokens.color.blue_500
+  let gradientDef = null
+
+  if (gradient && tokens.gradients[gradient]) {
+    const id = gradient + '_' + nanoid()
+    const config = tokens.gradients[gradient]
+    _fill = `url(#${id})`
+    gradientDef = (
+      <Defs>
+        <LinearGradient
+          id={id}
+          x1="0"
+          y1="0"
+          x2="100%"
+          y2="0"
+          gradientTransform="rotate(45)">
+          {config.values.map(([stop, fill]) => (
+            <Stop key={stop} offset={stop} stopColor={fill} />
+          ))}
+        </LinearGradient>
+      </Defs>
+    )
+  }
+
+  return {
+    fill: _fill,
+    size: _size,
+    style,
+    gradient: gradientDef,
+    ...rest,
+  }
+}
diff --git a/src/lib/browser.native.ts b/src/lib/browser.native.ts
index fb9be56f1..8e045138c 100644
--- a/src/lib/browser.native.ts
+++ b/src/lib/browser.native.ts
@@ -1,3 +1,4 @@
 export const isSafari = false
 export const isFirefox = false
 export const isTouchDevice = true
+export const isAndroidWeb = false
diff --git a/src/lib/browser.ts b/src/lib/browser.ts
index d178a9a64..08c43fbfd 100644
--- a/src/lib/browser.ts
+++ b/src/lib/browser.ts
@@ -5,3 +5,5 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test(
 export const isFirefox = /firefox|fxios/i.test(navigator.userAgent)
 export const isTouchDevice =
   'ontouchstart' in window || navigator.maxTouchPoints > 1
+export const isAndroidWeb =
+  /android/i.test(navigator.userAgent) && isTouchDevice
diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts
new file mode 100644
index 000000000..64d30a954
--- /dev/null
+++ b/src/lib/generate-starterpack.ts
@@ -0,0 +1,164 @@
+import {
+  AppBskyActorDefs,
+  AppBskyGraphGetStarterPack,
+  BskyAgent,
+  Facet,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {until} from 'lib/async/until'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {enforceLen} from 'lib/strings/helpers'
+import {useAgent} from 'state/session'
+
+export const createStarterPackList = async ({
+  name,
+  description,
+  descriptionFacets,
+  profiles,
+  agent,
+}: {
+  name: string
+  description?: string
+  descriptionFacets?: Facet[]
+  profiles: AppBskyActorDefs.ProfileViewBasic[]
+  agent: BskyAgent
+}): Promise<{uri: string; cid: string}> => {
+  if (profiles.length === 0) throw new Error('No profiles given')
+
+  const list = await agent.app.bsky.graph.list.create(
+    {repo: agent.session!.did},
+    {
+      name,
+      description,
+      descriptionFacets,
+      avatar: undefined,
+      createdAt: new Date().toISOString(),
+      purpose: 'app.bsky.graph.defs#referencelist',
+    },
+  )
+  if (!list) throw new Error('List creation failed')
+  await agent.com.atproto.repo.applyWrites({
+    repo: agent.session!.did,
+    writes: [
+      createListItem({did: agent.session!.did, listUri: list.uri}),
+    ].concat(
+      profiles
+        // Ensure we don't have ourselves in this list twice
+        .filter(p => p.did !== agent.session!.did)
+        .map(p => createListItem({did: p.did, listUri: list.uri})),
+    ),
+  })
+
+  return list
+}
+
+export function useGenerateStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: ({uri, cid}: {uri: string; cid: string}) => void
+  onError: (e: Error) => void
+}) {
+  const {_} = useLingui()
+  const agent = useAgent()
+  const starterPackString = _(msg`Starter Pack`)
+
+  return useMutation<{uri: string; cid: string}, Error, void>({
+    mutationFn: async () => {
+      let profile: AppBskyActorDefs.ProfileViewBasic | undefined
+      let profiles: AppBskyActorDefs.ProfileViewBasic[] | undefined
+
+      await Promise.all([
+        (async () => {
+          profile = (
+            await agent.app.bsky.actor.getProfile({
+              actor: agent.session!.did,
+            })
+          ).data
+        })(),
+        (async () => {
+          profiles = (
+            await agent.app.bsky.actor.searchActors({
+              q: encodeURIComponent('*'),
+              limit: 49,
+            })
+          ).data.actors.filter(p => p.viewer?.following)
+        })(),
+      ])
+
+      if (!profile || !profiles) {
+        throw new Error('ERROR_DATA')
+      }
+
+      // We include ourselves when we make the list
+      if (profiles.length < 7) {
+        throw new Error('NOT_ENOUGH_FOLLOWERS')
+      }
+
+      const displayName = enforceLen(
+        profile.displayName
+          ? sanitizeDisplayName(profile.displayName)
+          : `@${sanitizeHandle(profile.handle)}`,
+        25,
+        true,
+      )
+      const starterPackName = `${displayName}'s ${starterPackString}`
+
+      const list = await createStarterPackList({
+        name: starterPackName,
+        profiles,
+        agent,
+      })
+
+      return await agent.app.bsky.graph.starterpack.create(
+        {
+          repo: agent.session!.did,
+        },
+        {
+          name: starterPackName,
+          list: list.uri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+    },
+    onSuccess: async data => {
+      await whenAppViewReady(agent, data.uri, v => {
+        return typeof v?.data.starterPack.uri === 'string'
+      })
+      onSuccess(data)
+    },
+    onError: error => {
+      onError(error)
+    },
+  })
+}
+
+function createListItem({did, listUri}: {did: string; listUri: string}) {
+  return {
+    $type: 'com.atproto.repo.applyWrites#create',
+    collection: 'app.bsky.graph.listitem',
+    value: {
+      $type: 'app.bsky.graph.listitem',
+      subject: did,
+      list: listUri,
+      createdAt: new Date().toISOString(),
+    },
+  }
+}
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  uri: string,
+  fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () => agent.app.bsky.graph.getStarterPack({starterPack: uri}),
+  )
+}
diff --git a/src/lib/hooks/useBottomBarOffset.ts b/src/lib/hooks/useBottomBarOffset.ts
new file mode 100644
index 000000000..945c98062
--- /dev/null
+++ b/src/lib/hooks/useBottomBarOffset.ts
@@ -0,0 +1,14 @@
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {clamp} from 'lib/numbers'
+import {isWeb} from 'platform/detection'
+
+export function useBottomBarOffset(modifier: number = 0) {
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {bottom: bottomInset} = useSafeAreaInsets()
+  return (
+    (isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)) +
+    modifier
+  )
+}
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 347062beb..e4e7e1474 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -26,6 +26,7 @@ type NotificationReason =
   | 'reply'
   | 'quote'
   | 'chat-message'
+  | 'starterpack-joined'
 
 type NotificationPayload =
   | {
@@ -142,6 +143,7 @@ export function useNotificationsHandler() {
           case 'mention':
           case 'quote':
           case 'reply':
+          case 'starterpack-joined':
             resetToTab('NotificationsTab')
             break
           // TODO implement these after we have an idea of how to handle each individual case
diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts
new file mode 100644
index 000000000..16135b274
--- /dev/null
+++ b/src/lib/moderation/create-sanitized-display-name.ts
@@ -0,0 +1,21 @@
+import {AppBskyActorDefs} from '@atproto/api'
+
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+
+export function createSanitizedDisplayName(
+  profile:
+    | AppBskyActorDefs.ProfileViewBasic
+    | AppBskyActorDefs.ProfileViewDetailed,
+  noAt = false,
+) {
+  if (profile.displayName != null && profile.displayName !== '') {
+    return sanitizeDisplayName(profile.displayName)
+  } else {
+    let sanitizedHandle = sanitizeHandle(profile.handle)
+    if (!noAt) {
+      sanitizedHandle = `@${sanitizedHandle}`
+    }
+    return sanitizedHandle
+  }
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
index 54b727b76..91656857e 100644
--- a/src/lib/moderation/useReportOptions.ts
+++ b/src/lib/moderation/useReportOptions.ts
@@ -13,6 +13,7 @@ interface ReportOptions {
   account: ReportOption[]
   post: ReportOption[]
   list: ReportOption[]
+  starterpack: ReportOption[]
   feedgen: ReportOption[]
   other: ReportOption[]
   convoMessage: ReportOption[]
@@ -94,6 +95,14 @@ export function useReportOptions(): ReportOptions {
         },
         ...common,
       ],
+      starterpack: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
       feedgen: [
         {
           reason: ComAtprotoModerationDefs.REASONVIOLATION,
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 9dfdab909..56b716677 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -1,3 +1,5 @@
+import {AppBskyGraphDefs, AtUri} from '@atproto/api'
+
 import {isInvalidHandle} from 'lib/strings/handles'
 
 export function makeProfileLink(
@@ -35,3 +37,18 @@ export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
     props.query + (props.from ? ` from:${props.from}` : ''),
   )}`
 }
+
+export function makeStarterPackLink(
+  starterPackOrName:
+    | AppBskyGraphDefs.StarterPackViewBasic
+    | AppBskyGraphDefs.StarterPackView
+    | string,
+  rkey?: string,
+) {
+  if (typeof starterPackOrName === 'string') {
+    return `https://bsky.app/start/${starterPackOrName}/${rkey}`
+  } else {
+    const uriRkey = new AtUri(starterPackOrName.uri).rkey
+    return `https://bsky.app/start/${starterPackOrName.creator.handle}/${uriRkey}`
+  }
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 403c2bb67..8a173b675 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -42,6 +42,12 @@ export type CommonNavigatorParams = {
   MessagesConversation: {conversation: string; embed?: string}
   MessagesSettings: undefined
   Feeds: undefined
+  Start: {name: string; rkey: string}
+  StarterPack: {name: string; rkey: string; new?: boolean}
+  StarterPackWizard: undefined
+  StarterPackEdit: {
+    rkey?: string
+  }
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -93,6 +99,12 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   Hashtag: {tag: string; author?: string}
   MessagesTab: undefined
   Messages: {animation?: 'push' | 'pop'}
+  Start: {name: string; rkey: string}
+  StarterPack: {name: string; rkey: string; new?: boolean}
+  StarterPackWizard: undefined
+  StarterPackEdit: {
+    rkey?: string
+  }
 }
 
 // NOTE
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 2e8cedb54..07ed8c0ca 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -53,7 +53,14 @@ export type LogEvents = {
   }
   'onboarding:moderation:nextPressed': {}
   'onboarding:profile:nextPressed': {}
-  'onboarding:finished:nextPressed': {}
+  'onboarding:finished:nextPressed': {
+    usedStarterPack: boolean
+    starterPackName?: string
+    starterPackCreator?: string
+    starterPackUri?: string
+    profilesFollowed: number
+    feedsPinned: number
+  }
   'onboarding:finished:avatarResult': {
     avatarResult: 'default' | 'created' | 'uploaded'
   }
@@ -61,7 +68,12 @@ export type LogEvents = {
     feedUrl: string
     feedType: string
     index: number
-    reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click'
+    reason:
+      | 'focus'
+      | 'tabbar-click'
+      | 'pager-swipe'
+      | 'desktop-sidebar-click'
+      | 'starter-pack-initial-feed'
   }
   'feed:endReached:sampled': {
     feedUrl: string
@@ -134,6 +146,7 @@ export type LogEvents = {
       | 'ProfileMenu'
       | 'ProfileHoverCard'
       | 'AvatarButton'
+      | 'StarterPackProfilesList'
   }
   'profile:unfollow': {
     logContext:
@@ -146,6 +159,7 @@ export type LogEvents = {
       | 'ProfileHoverCard'
       | 'Chat'
       | 'AvatarButton'
+      | 'StarterPackProfilesList'
   }
   'chat:create': {
     logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -157,6 +171,23 @@ export type LogEvents = {
       | 'ChatsList'
       | 'SendViaChatDialog'
   }
+  'starterPack:share': {
+    starterPack: string
+    shareType: 'link' | 'qrcode'
+    qrShareType?: 'save' | 'copy' | 'share'
+  }
+  'starterPack:followAll': {
+    logContext: 'StarterPackProfilesList' | 'Onboarding'
+    starterPack: string
+    count: number
+  }
+  'starterPack:delete': {}
+  'starterPack:create': {
+    setName: boolean
+    setDescription: boolean
+    profilesCount: number
+    feedsCount: number
+  }
 
   'test:all:always': {}
   'test:all:sometimes': {}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 46ef934ef..bf2484ccb 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -5,3 +5,4 @@ export type Gate =
   | 'request_notifications_permission_after_onboarding_v2'
   | 'show_avi_follow_button'
   | 'show_follow_back_label_v2'
+  | 'starter_packs_enabled'
diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts
new file mode 100644
index 000000000..489d0b923
--- /dev/null
+++ b/src/lib/strings/starter-pack.ts
@@ -0,0 +1,101 @@
+import {AppBskyGraphDefs, AtUri} from '@atproto/api'
+
+export function createStarterPackLinkFromAndroidReferrer(
+  referrerQueryString: string,
+): string | null {
+  try {
+    // The referrer string is just some URL parameters, so lets add them to a fake URL
+    const url = new URL('http://throwaway.com/?' + referrerQueryString)
+    const utmContent = url.searchParams.get('utm_content')
+    const utmSource = url.searchParams.get('utm_source')
+
+    if (!utmContent) return null
+    if (utmSource !== 'bluesky') return null
+
+    // This should be a string like `starterpack_haileyok.com_rkey`
+    const contentParts = utmContent.split('_')
+
+    if (contentParts[0] !== 'starterpack') return null
+    if (contentParts.length !== 3) return null
+
+    return `at://${contentParts[1]}/app.bsky.graph.starterpack/${contentParts[2]}`
+  } catch (e) {
+    return null
+  }
+}
+
+export function parseStarterPackUri(uri?: string): {
+  name: string
+  rkey: string
+} | null {
+  if (!uri) return null
+
+  try {
+    if (uri.startsWith('at://')) {
+      const atUri = new AtUri(uri)
+      if (atUri.collection !== 'app.bsky.graph.starterpack') return null
+      if (atUri.rkey) {
+        return {
+          name: atUri.hostname,
+          rkey: atUri.rkey,
+        }
+      }
+      return null
+    } else {
+      const url = new URL(uri)
+      const parts = url.pathname.split('/')
+      const [_, path, name, rkey] = parts
+
+      if (parts.length !== 4) return null
+      if (path !== 'starter-pack' && path !== 'start') return null
+      if (!name || !rkey) return null
+      return {
+        name,
+        rkey,
+      }
+    }
+  } catch (e) {
+    return null
+  }
+}
+
+export function createStarterPackGooglePlayUri(
+  name: string,
+  rkey: string,
+): string | null {
+  if (!name || !rkey) return null
+  return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_${name}_${rkey}`
+}
+
+export function httpStarterPackUriToAtUri(httpUri?: string): string | null {
+  if (!httpUri) return null
+
+  const parsed = parseStarterPackUri(httpUri)
+  if (!parsed) return null
+
+  if (httpUri.startsWith('at://')) return httpUri
+
+  return `at://${parsed.name}/app.bsky.graph.starterpack/${parsed.rkey}`
+}
+
+export function getStarterPackOgCard(
+  didOrStarterPack: AppBskyGraphDefs.StarterPackView | string,
+  rkey?: string,
+) {
+  if (typeof didOrStarterPack === 'string') {
+    return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack}/${rkey}`
+  } else {
+    const rkey = new AtUri(didOrStarterPack.uri).rkey
+    return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack.creator.did}/${rkey}`
+  }
+}
+
+export function createStarterPackUri({
+  did,
+  rkey,
+}: {
+  did: string
+  rkey: string
+}): string | null {
+  return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString()
+}
diff --git a/src/routes.ts b/src/routes.ts
index de711f5dc..f241d37a0 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -41,4 +41,8 @@ export const router = new Router({
   Messages: '/messages',
   MessagesSettings: '/messages/settings',
   MessagesConversation: '/messages/:conversation',
+  Start: '/start/:name/:rkey',
+  StarterPackEdit: '/starter-pack/edit/:rkey',
+  StarterPack: '/starter-pack/:name/:rkey',
+  StarterPackWizard: '/starter-pack/create',
 })
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
index dfa10668b..7cfd38e34 100644
--- a/src/screens/Login/LoginForm.tsx
+++ b/src/screens/Login/LoginForm.tsx
@@ -21,6 +21,7 @@ import {logger} from '#/logger'
 import {useSessionApi} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
+import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {FormError} from '#/components/forms/FormError'
@@ -69,6 +70,7 @@ export const LoginForm = ({
   const {login} = useSessionApi()
   const requestNotificationsPermission = useRequestNotificationsPermission()
   const {setShowLoggedOut} = useLoggedOutViewControls()
+  const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
 
   const onPressSelectService = React.useCallback(() => {
     Keyboard.dismiss()
@@ -116,6 +118,7 @@ export const LoginForm = ({
         'LoginForm',
       )
       setShowLoggedOut(false)
+      setHasCheckedForStarterPack(true)
       requestNotificationsPermission('Login')
     } catch (e: any) {
       const errMsg = e.toString()
diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx
index ab0a22367..6fad26680 100644
--- a/src/screens/Login/ScreenTransition.tsx
+++ b/src/screens/Login/ScreenTransition.tsx
@@ -1,9 +1,16 @@
 import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
 import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
 
-export function ScreenTransition({children}: {children: React.ReactNode}) {
+export function ScreenTransition({
+  style,
+  children,
+}: {
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
   return (
-    <Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
+    <Animated.View style={style} entering={FadeInRight} exiting={FadeOutLeft}>
       {children}
     </Animated.View>
   )
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index c75dd4fa7..c7a459659 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,11 +1,18 @@
 import React from 'react'
 import {View} from 'react-native'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs'
+import {TID} from '@atproto/common-web'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
-import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
+import {
+  BSKY_APP_ACCOUNT_DID,
+  DISCOVER_SAVED_FEED,
+  TIMELINE_SAVED_FEED,
+} from '#/lib/constants'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {preferencesQueryKey} from '#/state/queries/preferences'
@@ -14,6 +21,11 @@ import {useAgent} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
 import {uploadBlob} from 'lib/api'
 import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
+import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
+import {
+  useActiveStarterPack,
+  useSetActiveStarterPack,
+} from 'state/shell/starter-pack'
 import {
   DescriptionText,
   OnboardingControls,
@@ -41,17 +53,74 @@ export function StepFinished() {
   const queryClient = useQueryClient()
   const agent = useAgent()
   const requestNotificationsPermission = useRequestNotificationsPermission()
+  const activeStarterPack = useActiveStarterPack()
+  const setActiveStarterPack = useSetActiveStarterPack()
+  const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
 
   const finishOnboarding = React.useCallback(async () => {
     setSaving(true)
 
-    const {interestsStepResults, profileStepResults} = state
-    const {selectedInterests} = interestsStepResults
+    let starterPack: AppBskyGraphDefs.StarterPackView | undefined
+    let listItems: AppBskyGraphDefs.ListItemView[] | undefined
+
+    if (activeStarterPack?.uri) {
+      try {
+        const spRes = await agent.app.bsky.graph.getStarterPack({
+          starterPack: activeStarterPack.uri,
+        })
+        starterPack = spRes.data.starterPack
+
+        if (starterPack.list) {
+          const listRes = await agent.app.bsky.graph.getList({
+            list: starterPack.list.uri,
+            limit: 50,
+          })
+          listItems = listRes.data.items
+        }
+      } catch (e) {
+        logger.error('Failed to fetch starter pack', {safeMessage: e})
+        // don't tell the user, just get them through onboarding.
+      }
+    }
+
     try {
+      const {interestsStepResults, profileStepResults} = state
+      const {selectedInterests} = interestsStepResults
+
       await Promise.all([
-        bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]),
+        bulkWriteFollows(agent, [
+          BSKY_APP_ACCOUNT_DID,
+          ...(listItems?.map(i => i.subject.did) ?? []),
+        ]),
         (async () => {
+          // Interests need to get saved first, then we can write the feeds to prefs
           await agent.setInterestsPref({tags: selectedInterests})
+
+          // Default feeds that every user should have pinned when landing in the app
+          const feedsToSave: SavedFeed[] = [
+            {
+              ...DISCOVER_SAVED_FEED,
+              id: TID.nextStr(),
+            },
+            {
+              ...TIMELINE_SAVED_FEED,
+              id: TID.nextStr(),
+            },
+          ]
+
+          // Any starter pack feeds will be pinned _after_ the defaults
+          if (starterPack && starterPack.feeds?.length) {
+            feedsToSave.concat(
+              starterPack.feeds.map(f => ({
+                type: 'feed',
+                value: f.uri,
+                pinned: true,
+                id: TID.nextStr(),
+              })),
+            )
+          }
+
+          await agent.overwriteSavedFeeds(feedsToSave)
         })(),
         (async () => {
           const {imageUri, imageMime} = profileStepResults
@@ -63,9 +132,24 @@ export function StepFinished() {
               if (res.data.blob) {
                 existing.avatar = res.data.blob
               }
+
+              if (starterPack) {
+                existing.joinedViaStarterPack = {
+                  uri: starterPack.uri,
+                  cid: starterPack.cid,
+                }
+              }
+
+              existing.displayName = ''
+              // HACKFIX
+              // creating a bunch of identical profile objects is breaking the relay
+              // tossing this unspecced field onto it to reduce the size of the problem
+              // -prf
+              existing.createdAt = new Date().toISOString()
               return existing
             })
           }
+
           logEvent('onboarding:finished:avatarResult', {
             avatarResult: profileStepResults.isCreatedAvatar
               ? 'created'
@@ -96,19 +180,40 @@ export function StepFinished() {
     })
 
     setSaving(false)
+    setActiveStarterPack(undefined)
+    setHasCheckedForStarterPack(true)
     dispatch({type: 'finish'})
     onboardDispatch({type: 'finish'})
     track('OnboardingV2:StepFinished:End')
     track('OnboardingV2:Complete')
-    logEvent('onboarding:finished:nextPressed', {})
+    logEvent('onboarding:finished:nextPressed', {
+      usedStarterPack: Boolean(starterPack),
+      starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record)
+        ? starterPack.record.name
+        : undefined,
+      starterPackCreator: starterPack?.creator.did,
+      starterPackUri: starterPack?.uri,
+      profilesFollowed: listItems?.length ?? 0,
+      feedsPinned: starterPack?.feeds?.length ?? 0,
+    })
+    if (starterPack && listItems?.length) {
+      logEvent('starterPack:followAll', {
+        logContext: 'Onboarding',
+        starterPack: starterPack.uri,
+        count: listItems?.length,
+      })
+    }
   }, [
-    state,
     queryClient,
     agent,
     dispatch,
     onboardDispatch,
     track,
+    activeStarterPack,
+    state,
     requestNotificationsPermission,
+    setActiveStarterPack,
+    setHasCheckedForStarterPack,
   ])
 
   React.useEffect(() => {
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
index b6d88db71..c63658a44 100644
--- a/src/screens/Profile/Header/DisplayName.tsx
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {View} from 'react-native'
 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {Shadow} from '#/state/cache/types'
 
+import {Shadow} from '#/state/cache/types'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
index 2cc1bcab0..3203d443c 100644
--- a/src/screens/Signup/index.tsx
+++ b/src/screens/Signup/index.tsx
@@ -1,6 +1,11 @@
 import React from 'react'
 import {View} from 'react-native'
-import {LayoutAnimationConfig} from 'react-native-reanimated'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+} from 'react-native-reanimated'
+import {AppBskyGraphStarterpack} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles'
 import {logger} from '#/logger'
 import {useServiceQuery} from '#/state/queries/service'
 import {useAgent} from '#/state/session'
+import {useStarterPackQuery} from 'state/queries/starter-packs'
+import {useActiveStarterPack} from 'state/shell/starter-pack'
 import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
 import {
   initialState,
@@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
 import {Button, ButtonText} from '#/components/Button'
 import {Divider} from '#/components/Divider'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
 import {InlineLinkText} from '#/components/Link'
 import {Text} from '#/components/Typography'
 
@@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
   const {gtMobile} = useBreakpoints()
   const agent = useAgent()
 
+  const activeStarterPack = useActiveStarterPack()
+  const {data: starterPack} = useStarterPackQuery({
+    uri: activeStarterPack?.uri,
+  })
+
   const {
     data: serviceInfo,
     isFetching,
@@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
         description={_(msg`We're so excited to have you join us!`)}
         scrollable>
         <View testID="createAccount" style={a.flex_1}>
+          {state.activeStep === SignupStep.INFO &&
+          starterPack &&
+          AppBskyGraphStarterpack.isRecord(starterPack.record) ? (
+            <Animated.View entering={FadeIn} exiting={FadeOut}>
+              <LinearGradientBackground
+                style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}>
+                <Text style={[a.font_bold, a.text_xl, {color: 'white'}]}>
+                  {starterPack.record.name}
+                </Text>
+                <Text style={[{color: 'white'}]}>
+                  {starterPack.feeds?.length ? (
+                    <Trans>
+                      You'll follow the suggested users and feeds once you
+                      finish creating your account!
+                    </Trans>
+                  ) : (
+                    <Trans>
+                      You'll follow the suggested users once you finish creating
+                      your account!
+                    </Trans>
+                  )}
+                </Text>
+              </LinearGradientBackground>
+            </Animated.View>
+          ) : null}
           <View
             style={[
               a.flex_1,
diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx
new file mode 100644
index 000000000..1c9587a79
--- /dev/null
+++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx
@@ -0,0 +1,378 @@
+import React from 'react'
+import {Pressable, ScrollView, View} from 'react-native'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {
+  AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isAndroidWeb} from 'lib/browser'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
+import {useStarterPackQuery} from 'state/queries/starter-packs'
+import {
+  useActiveStarterPack,
+  useSetActiveStarterPack,
+} from 'state/shell/starter-pack'
+import {LoggedOutScreenState} from 'view/com/auth/LoggedOut'
+import {CenteredView} from 'view/com/util/Views'
+import {Logo} from 'view/icons/Logo'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import * as FeedCard from '#/components/FeedCard'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {ListMaybePlaceholder} from '#/components/Lists'
+import {Default as ProfileCard} from '#/components/ProfileCard'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+
+interface AppClipMessage {
+  action: 'present' | 'store'
+  keyToStoreAs?: string
+  jsonToStore?: string
+}
+
+function postAppClipMessage(message: AppClipMessage) {
+  // @ts-expect-error safari webview only
+  window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message))
+}
+
+export function LandingScreen({
+  setScreenState,
+}: {
+  setScreenState: (state: LoggedOutScreenState) => void
+}) {
+  const moderationOpts = useModerationOpts()
+  const activeStarterPack = useActiveStarterPack()
+
+  const {data: starterPack, isError: isErrorStarterPack} = useStarterPackQuery({
+    uri: activeStarterPack?.uri,
+  })
+
+  const isValid =
+    starterPack &&
+    starterPack.list &&
+    AppBskyGraphDefs.validateStarterPackView(starterPack) &&
+    AppBskyGraphStarterpack.validateRecord(starterPack.record)
+
+  React.useEffect(() => {
+    if (isErrorStarterPack || (starterPack && !isValid)) {
+      setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount)
+    }
+  }, [isErrorStarterPack, setScreenState, isValid, starterPack])
+
+  if (!starterPack || !isValid || !moderationOpts) {
+    return <ListMaybePlaceholder isLoading={true} />
+  }
+
+  return (
+    <LandingScreenLoaded
+      starterPack={starterPack}
+      setScreenState={setScreenState}
+      moderationOpts={moderationOpts}
+    />
+  )
+}
+
+function LandingScreenLoaded({
+  starterPack,
+  setScreenState,
+  // TODO apply this to profile card
+
+  moderationOpts,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  setScreenState: (state: LoggedOutScreenState) => void
+  moderationOpts: ModerationOpts
+}) {
+  const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack
+  const {_} = useLingui()
+  const t = useTheme()
+  const activeStarterPack = useActiveStarterPack()
+  const setActiveStarterPack = useSetActiveStarterPack()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const androidDialogControl = useDialogControl()
+
+  const [appClipOverlayVisible, setAppClipOverlayVisible] =
+    React.useState(false)
+
+  const listItemsCount = starterPack.list?.listItemCount ?? 0
+
+  const onContinue = () => {
+    setActiveStarterPack({
+      uri: starterPack.uri,
+    })
+    setScreenState(LoggedOutScreenState.S_CreateAccount)
+  }
+
+  const onJoinPress = () => {
+    if (activeStarterPack?.isClip) {
+      setAppClipOverlayVisible(true)
+      postAppClipMessage({
+        action: 'present',
+      })
+    } else if (isAndroidWeb) {
+      androidDialogControl.open()
+    } else {
+      onContinue()
+    }
+  }
+
+  const onJoinWithoutPress = () => {
+    if (activeStarterPack?.isClip) {
+      setAppClipOverlayVisible(true)
+      postAppClipMessage({
+        action: 'present',
+      })
+    } else {
+      setActiveStarterPack(undefined)
+      setScreenState(LoggedOutScreenState.S_CreateAccount)
+    }
+  }
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <CenteredView style={a.flex_1}>
+      <ScrollView
+        style={[a.flex_1, t.atoms.bg]}
+        contentContainerStyle={{paddingBottom: 100}}>
+        <LinearGradientBackground
+          style={[
+            a.align_center,
+            a.gap_sm,
+            a.px_lg,
+            a.py_2xl,
+            isTabletOrDesktop && [a.mt_2xl, a.rounded_md],
+            activeStarterPack?.isClip && {
+              paddingTop: 100,
+            },
+          ]}>
+          <View style={[a.flex_row, a.gap_md, a.pb_sm]}>
+            <Logo width={76} fill="white" />
+          </View>
+          <Text
+            style={[
+              a.font_bold,
+              a.text_4xl,
+              a.text_center,
+              a.leading_tight,
+              {color: 'white'},
+            ]}>
+            {record.name}
+          </Text>
+          <Text
+            style={[
+              a.text_center,
+              a.font_semibold,
+              a.text_md,
+              {color: 'white'},
+            ]}>
+            Starter pack by {`@${creator.handle}`}
+          </Text>
+        </LinearGradientBackground>
+        <View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}>
+          {record.description ? (
+            <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+              {record.description}
+            </Text>
+          ) : null}
+          <View style={[a.gap_sm]}>
+            <Button
+              label={_(msg`Join Bluesky`)}
+              onPress={onJoinPress}
+              variant="solid"
+              color="primary"
+              size="large">
+              <ButtonText style={[a.text_lg]}>
+                <Trans>Join Bluesky</Trans>
+              </ButtonText>
+            </Button>
+            {joinedWeekCount && joinedWeekCount >= 25 ? (
+              <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                <FontAwesomeIcon
+                  icon="arrow-trend-up"
+                  size={12}
+                  color={t.atoms.text_contrast_medium.color}
+                />
+                <Text
+                  style={[
+                    a.font_semibold,
+                    a.text_sm,
+                    t.atoms.text_contrast_medium,
+                  ]}
+                  numberOfLines={1}>
+                  123,659 joined this week
+                </Text>
+              </View>
+            ) : null}
+          </View>
+          <View style={[a.gap_3xl]}>
+            {Boolean(listItemsSample?.length) && (
+              <View style={[a.gap_md]}>
+                <Text style={[a.font_heavy, a.text_lg]}>
+                  {listItemsCount <= 8 ? (
+                    <Trans>You'll follow these people right away</Trans>
+                  ) : (
+                    <Trans>
+                      You'll follow these people and {listItemsCount - 8} others
+                    </Trans>
+                  )}
+                </Text>
+                <View>
+                  {starterPack.listItemsSample?.slice(0, 8).map(item => (
+                    <View
+                      key={item.subject.did}
+                      style={[
+                        a.py_lg,
+                        a.px_md,
+                        a.border_t,
+                        t.atoms.border_contrast_low,
+                      ]}>
+                      <ProfileCard
+                        profile={item.subject}
+                        moderationOpts={moderationOpts}
+                      />
+                    </View>
+                  ))}
+                </View>
+              </View>
+            )}
+            {feeds?.length ? (
+              <View style={[a.gap_md]}>
+                <Text style={[a.font_heavy, a.text_lg]}>
+                  <Trans>You'll stay updated with these feeds</Trans>
+                </Text>
+
+                <View style={[{pointerEvents: 'none'}]}>
+                  {feeds?.map(feed => (
+                    <View
+                      style={[
+                        a.py_lg,
+                        a.px_md,
+                        a.border_t,
+                        t.atoms.border_contrast_low,
+                      ]}
+                      key={feed.uri}>
+                      <FeedCard.Default type="feed" view={feed} />
+                    </View>
+                  ))}
+                </View>
+              </View>
+            ) : null}
+          </View>
+          <Button
+            label={_(msg`Signup without a starter pack`)}
+            variant="solid"
+            color="secondary"
+            size="medium"
+            style={[a.py_lg]}
+            onPress={onJoinWithoutPress}>
+            <ButtonText>
+              <Trans>Signup without a starter pack</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </ScrollView>
+      <AppClipOverlay
+        visible={appClipOverlayVisible}
+        setIsVisible={setAppClipOverlayVisible}
+      />
+      <Prompt.Outer control={androidDialogControl}>
+        <Prompt.TitleText>
+          <Trans>Download Bluesky</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>
+            The experience is better in the app. Download Bluesky now and we'll
+            pick back up where you left off.
+          </Trans>
+        </Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            cta="Download on Google Play"
+            color="primary"
+            onPress={() => {
+              const rkey = new AtUri(starterPack.uri).rkey
+              if (!rkey) return
+
+              const googlePlayUri = createStarterPackGooglePlayUri(
+                creator.handle,
+                rkey,
+              )
+              if (!googlePlayUri) return
+
+              window.location.href = googlePlayUri
+            }}
+          />
+          <Prompt.Action
+            cta="Continue on web"
+            color="secondary"
+            onPress={onContinue}
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+      {isWeb && (
+        <meta
+          name="apple-itunes-app"
+          content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card"
+        />
+      )}
+    </CenteredView>
+  )
+}
+
+function AppClipOverlay({
+  visible,
+  setIsVisible,
+}: {
+  visible: boolean
+  setIsVisible: (visible: boolean) => void
+}) {
+  if (!visible) return
+
+  return (
+    <AnimatedPressable
+      accessibilityRole="button"
+      style={[
+        a.absolute,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          backgroundColor: 'rgba(0, 0, 0, 0.95)',
+          zIndex: 1,
+        },
+      ]}
+      entering={FadeIn}
+      exiting={FadeOut}
+      onPress={() => setIsVisible(false)}>
+      <View style={[a.flex_1, a.px_lg, {marginTop: 250}]}>
+        {/* Webkit needs this to have a zindex of 2? */}
+        <View style={[a.gap_md, {zIndex: 2}]}>
+          <Text
+            style={[a.font_bold, a.text_4xl, {lineHeight: 40, color: 'white'}]}>
+            Download Bluesky to get started!
+          </Text>
+          <Text style={[a.text_lg, {color: 'white'}]}>
+            We'll remember the starter pack you chose and use it when you create
+            an account in the app.
+          </Text>
+        </View>
+      </View>
+    </AnimatedPressable>
+  )
+}
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
new file mode 100644
index 000000000..46ce25236
--- /dev/null
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -0,0 +1,627 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {
+  AppBskyGraphDefs,
+  AppBskyGraphGetList,
+  AppBskyGraphStarterpack,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {
+  InfiniteData,
+  UseInfiniteQueryResult,
+  useQueryClient,
+} from '@tanstack/react-query'
+
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
+import {HITSLOP_20} from 'lib/constants'
+import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
+import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
+import {logEvent} from 'lib/statsig/statsig'
+import {getStarterPackOgCard} from 'lib/strings/starter-pack'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
+import {RQKEY, useListMembersQuery} from 'state/queries/list-members'
+import {useResolveDidQuery} from 'state/queries/resolve-uri'
+import {useShortenLink} from 'state/queries/shorten-link'
+import {useStarterPackQuery} from 'state/queries/starter-packs'
+import {useAgent, useSession} from 'state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
+import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {bulkWriteFollows} from '#/screens/Onboarding/util'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
+import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {ListMaybePlaceholder} from '#/components/Lists'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
+import {FeedsList} from '#/components/StarterPack/Main/FeedsList'
+import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList'
+import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog'
+import {ShareDialog} from '#/components/StarterPack/ShareDialog'
+import {Text} from '#/components/Typography'
+
+type StarterPackScreeProps = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'StarterPack'
+>
+
+export function StarterPackScreen({route}: StarterPackScreeProps) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+
+  const {name, rkey} = route.params
+  const moderationOpts = useModerationOpts()
+  const {
+    data: did,
+    isLoading: isLoadingDid,
+    isError: isErrorDid,
+  } = useResolveDidQuery(name)
+  const {
+    data: starterPack,
+    isLoading: isLoadingStarterPack,
+    isError: isErrorStarterPack,
+  } = useStarterPackQuery({did, rkey})
+  const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50)
+
+  const isValid =
+    starterPack &&
+    (starterPack.list || starterPack?.creator?.did === currentAccount?.did) &&
+    AppBskyGraphDefs.validateStarterPackView(starterPack) &&
+    AppBskyGraphStarterpack.validateRecord(starterPack.record)
+
+  if (!did || !starterPack || !isValid || !moderationOpts) {
+    return (
+      <ListMaybePlaceholder
+        isLoading={
+          isLoadingDid ||
+          isLoadingStarterPack ||
+          listMembersQuery.isLoading ||
+          !moderationOpts
+        }
+        isError={isErrorDid || isErrorStarterPack || !isValid}
+        errorMessage={_(msg`That starter pack could not be found.`)}
+        emptyMessage={_(msg`That starter pack could not be found.`)}
+      />
+    )
+  }
+
+  if (!starterPack.list && starterPack.creator.did === currentAccount?.did) {
+    return <InvalidStarterPack rkey={rkey} />
+  }
+
+  return (
+    <StarterPackScreenInner
+      starterPack={starterPack}
+      routeParams={route.params}
+      listMembersQuery={listMembersQuery}
+      moderationOpts={moderationOpts}
+    />
+  )
+}
+
+function StarterPackScreenInner({
+  starterPack,
+  routeParams,
+  listMembersQuery,
+  moderationOpts,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  routeParams: StarterPackScreeProps['route']['params']
+  listMembersQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetList.OutputSchema>
+  >
+  moderationOpts: ModerationOpts
+}) {
+  const tabs = [
+    ...(starterPack.list ? ['People'] : []),
+    ...(starterPack.feeds?.length ? ['Feeds'] : []),
+  ]
+
+  const qrCodeDialogControl = useDialogControl()
+  const shareDialogControl = useDialogControl()
+
+  const shortenLink = useShortenLink()
+  const [link, setLink] = React.useState<string>()
+  const [imageLoaded, setImageLoaded] = React.useState(false)
+
+  const onOpenShareDialog = React.useCallback(() => {
+    const rkey = new AtUri(starterPack.uri).rkey
+    shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then(
+      res => {
+        setLink(res.url)
+      },
+    )
+    Image.prefetch(getStarterPackOgCard(starterPack))
+      .then(() => {
+        setImageLoaded(true)
+      })
+      .catch(() => {
+        setImageLoaded(true)
+      })
+    shareDialogControl.open()
+  }, [shareDialogControl, shortenLink, starterPack])
+
+  React.useEffect(() => {
+    if (routeParams.new) {
+      onOpenShareDialog()
+    }
+  }, [onOpenShareDialog, routeParams.new, shareDialogControl])
+
+  return (
+    <CenteredView style={[a.h_full_vh]}>
+      <View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}>
+        <PagerWithHeader
+          items={tabs}
+          isHeaderReady={true}
+          renderHeader={() => (
+            <Header
+              starterPack={starterPack}
+              routeParams={routeParams}
+              onOpenShareDialog={onOpenShareDialog}
+            />
+          )}>
+          {starterPack.list != null
+            ? ({headerHeight, scrollElRef}) => (
+                <ProfilesList
+                  key={0}
+                  // Validated above
+                  listUri={starterPack!.list!.uri}
+                  headerHeight={headerHeight}
+                  // @ts-expect-error
+                  scrollElRef={scrollElRef}
+                  listMembersQuery={listMembersQuery}
+                  moderationOpts={moderationOpts}
+                />
+              )
+            : null}
+          {starterPack.feeds != null
+            ? ({headerHeight, scrollElRef}) => (
+                <FeedsList
+                  key={1}
+                  // @ts-expect-error ?
+                  feeds={starterPack?.feeds}
+                  headerHeight={headerHeight}
+                  // @ts-expect-error
+                  scrollElRef={scrollElRef}
+                />
+              )
+            : null}
+        </PagerWithHeader>
+      </View>
+
+      <QrCodeDialog
+        control={qrCodeDialogControl}
+        starterPack={starterPack}
+        link={link}
+      />
+      <ShareDialog
+        control={shareDialogControl}
+        qrDialogControl={qrCodeDialogControl}
+        starterPack={starterPack}
+        link={link}
+        imageLoaded={imageLoaded}
+      />
+    </CenteredView>
+  )
+}
+
+function Header({
+  starterPack,
+  routeParams,
+  onOpenShareDialog,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  routeParams: StarterPackScreeProps['route']['params']
+  onOpenShareDialog: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+
+  const [isProcessing, setIsProcessing] = React.useState(false)
+
+  const {record, creator} = starterPack
+  const isOwn = creator?.did === currentAccount?.did
+  const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0
+
+  const onFollowAll = async () => {
+    if (!starterPack.list) return
+
+    setIsProcessing(true)
+
+    try {
+      const list = await agent.app.bsky.graph.getList({
+        list: starterPack.list.uri,
+      })
+      const dids = list.data.items
+        .filter(li => !li.subject.viewer?.following)
+        .map(li => li.subject.did)
+
+      await bulkWriteFollows(agent, dids)
+
+      await queryClient.refetchQueries({
+        queryKey: RQKEY(starterPack.list.uri),
+      })
+
+      logEvent('starterPack:followAll', {
+        logContext: 'StarterPackProfilesList',
+        starterPack: starterPack.uri,
+        count: dids.length,
+      })
+      Toast.show(_(msg`All accounts have been followed!`))
+    } catch (e) {
+      Toast.show(_(msg`An error occurred while trying to follow all`))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <>
+      <ProfileSubpageHeader
+        isLoading={false}
+        href={makeProfileLink(creator)}
+        title={record.name}
+        isOwner={isOwn}
+        avatar={undefined}
+        creator={creator}
+        avatarType="starter-pack">
+        <View style={[a.flex_row, a.gap_sm, a.align_center]}>
+          {isOwn ? (
+            <Button
+              label={_(msg`Share this starter pack`)}
+              hitSlop={HITSLOP_20}
+              variant="solid"
+              color="primary"
+              size="small"
+              onPress={onOpenShareDialog}>
+              <ButtonText>
+                <Trans>Share</Trans>
+              </ButtonText>
+            </Button>
+          ) : (
+            <Button
+              label={_(msg`Follow all`)}
+              variant="solid"
+              color="primary"
+              size="small"
+              disabled={isProcessing}
+              onPress={onFollowAll}>
+              <ButtonText>
+                <Trans>Follow all</Trans>
+                {isProcessing && <Loader size="xs" />}
+              </ButtonText>
+            </Button>
+          )}
+          <OverflowMenu
+            routeParams={routeParams}
+            starterPack={starterPack}
+            onOpenShareDialog={onOpenShareDialog}
+          />
+        </View>
+      </ProfileSubpageHeader>
+      {record.description || joinedAllTimeCount >= 25 ? (
+        <View style={[a.px_lg, a.pt_md, a.pb_sm, a.gap_md]}>
+          {record.description ? (
+            <Text style={[a.text_md, a.leading_snug]}>
+              {record.description}
+            </Text>
+          ) : null}
+          {joinedAllTimeCount >= 25 ? (
+            <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+              <FontAwesomeIcon
+                icon="arrow-trend-up"
+                size={12}
+                color={t.atoms.text_contrast_medium.color}
+              />
+              <Text
+                style={[a.font_bold, a.text_sm, t.atoms.text_contrast_medium]}>
+                <Trans>
+                  {starterPack.joinedAllTimeCount || 0} people have used this
+                  starter pack!
+                </Trans>
+              </Text>
+            </View>
+          ) : null}
+        </View>
+      ) : null}
+    </>
+  )
+}
+
+function OverflowMenu({
+  starterPack,
+  routeParams,
+  onOpenShareDialog,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  routeParams: StarterPackScreeProps['route']['params']
+  onOpenShareDialog: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const {currentAccount} = useSession()
+  const reportDialogControl = useReportDialogControl()
+  const deleteDialogControl = useDialogControl()
+  const navigation = useNavigation<NavigationProp>()
+
+  const {
+    mutate: deleteStarterPack,
+    isPending: isDeletePending,
+    error: deleteError,
+  } = useDeleteStarterPackMutation({
+    onSuccess: () => {
+      logEvent('starterPack:delete', {})
+      deleteDialogControl.close(() => {
+        if (navigation.canGoBack()) {
+          navigation.popToTop()
+        } else {
+          navigation.navigate('Home')
+        }
+      })
+    },
+    onError: e => {
+      logger.error('Failed to delete starter pack', {safeMessage: e})
+    },
+  })
+
+  const isOwn = starterPack.creator.did === currentAccount?.did
+
+  const onDeleteStarterPack = async () => {
+    if (!starterPack.list) {
+      logger.error(`Unable to delete starterpack because list is missing`)
+      return
+    }
+
+    deleteStarterPack({
+      rkey: routeParams.rkey,
+      listUri: starterPack.list.uri,
+    })
+    logEvent('starterPack:delete', {})
+  }
+
+  return (
+    <>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Repost or quote post`)}>
+          {({props}) => (
+            <Button
+              {...props}
+              testID="headerDropdownBtn"
+              label={_(msg`Open starter pack menu`)}
+              hitSlop={HITSLOP_20}
+              variant="solid"
+              color="secondary"
+              size="small"
+              shape="round">
+              <ButtonIcon icon={Ellipsis} />
+            </Button>
+          )}
+        </Menu.Trigger>
+        <Menu.Outer style={{minWidth: 170}}>
+          {isOwn ? (
+            <>
+              <Menu.Item
+                label={_(msg`Edit starter pack`)}
+                testID="editStarterPackLinkBtn"
+                onPress={() => {
+                  navigation.navigate('StarterPackEdit', {
+                    rkey: routeParams.rkey,
+                  })
+                }}>
+                <Menu.ItemText>
+                  <Trans>Edit</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Pencil} position="right" />
+              </Menu.Item>
+              <Menu.Item
+                label={_(msg`Delete starter pack`)}
+                testID="deleteStarterPackBtn"
+                onPress={() => {
+                  deleteDialogControl.open()
+                }}>
+                <Menu.ItemText>
+                  <Trans>Delete</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Trash} position="right" />
+              </Menu.Item>
+            </>
+          ) : (
+            <>
+              <Menu.Group>
+                <Menu.Item
+                  label={_(msg`Share`)}
+                  testID="shareStarterPackLinkBtn"
+                  onPress={onOpenShareDialog}>
+                  <Menu.ItemText>
+                    <Trans>Share link</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={ArrowOutOfBox} position="right" />
+                </Menu.Item>
+              </Menu.Group>
+
+              <Menu.Item
+                label={_(msg`Report starter pack`)}
+                onPress={reportDialogControl.open}>
+                <Menu.ItemText>
+                  <Trans>Report starter pack</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={CircleInfo} position="right" />
+              </Menu.Item>
+            </>
+          )}
+        </Menu.Outer>
+      </Menu.Root>
+
+      {starterPack.list && (
+        <ReportDialog
+          control={reportDialogControl}
+          params={{
+            type: 'starterpack',
+            uri: starterPack.uri,
+            cid: starterPack.cid,
+          }}
+        />
+      )}
+
+      <Prompt.Outer control={deleteDialogControl}>
+        <Prompt.TitleText>
+          <Trans>Delete starter pack?</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>Are you sure you want delete this starter pack?</Trans>
+        </Prompt.DescriptionText>
+        {deleteError && (
+          <View
+            style={[
+              a.flex_row,
+              a.gap_sm,
+              a.rounded_sm,
+              a.p_md,
+              a.mb_lg,
+              a.border,
+              t.atoms.border_contrast_medium,
+              t.atoms.bg_contrast_25,
+            ]}>
+            <View style={[a.flex_1, a.gap_2xs]}>
+              <Text style={[a.font_bold]}>
+                <Trans>Unable to delete</Trans>
+              </Text>
+              <Text style={[a.leading_snug]}>{cleanError(deleteError)}</Text>
+            </View>
+            <CircleInfo size="sm" fill={t.palette.negative_400} />
+          </View>
+        )}
+        <Prompt.Actions>
+          <Button
+            variant="solid"
+            color="negative"
+            size={gtMobile ? 'small' : 'medium'}
+            label={_(msg`Yes, delete this starter pack`)}
+            onPress={onDeleteStarterPack}>
+            <ButtonText>
+              <Trans>Delete</Trans>
+            </ButtonText>
+            {isDeletePending && <ButtonIcon icon={Loader} />}
+          </Button>
+          <Prompt.Cancel />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+function InvalidStarterPack({rkey}: {rkey: string}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const {gtMobile} = useBreakpoints()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+
+  const goBack = () => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.replace('Home')
+    }
+  }
+
+  const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({
+    onSuccess: () => {
+      setIsProcessing(false)
+      goBack()
+    },
+    onError: e => {
+      setIsProcessing(false)
+      logger.error('Failed to delete invalid starter pack', {safeMessage: e})
+      Toast.show(_(msg`Failed to delete starter pack`))
+    },
+  })
+
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        a.align_center,
+        a.gap_5xl,
+        !gtMobile && a.justify_between,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}
+      sideBorders={true}>
+      <View style={[a.w_full, a.align_center, a.gap_lg]}>
+        <Text style={[a.font_bold, a.text_3xl]}>
+          <Trans>Starter pack is invalid</Trans>
+        </Text>
+        <Text
+          style={[
+            a.text_md,
+            a.text_center,
+            t.atoms.text_contrast_high,
+            {lineHeight: 1.4},
+            gtMobile ? {width: 450} : [a.w_full, a.px_lg],
+          ]}>
+          <Trans>
+            The starter pack that you are trying to view is invalid. You may
+            delete this starter pack instead.
+          </Trans>
+        </Text>
+      </View>
+      <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
+        <Button
+          variant="solid"
+          color="primary"
+          label={_(msg`Delete starter pack`)}
+          size="large"
+          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}
+          disabled={isProcessing}
+          onPress={() => {
+            setIsProcessing(true)
+            deleteStarterPack({rkey})
+          }}>
+          <ButtonText>
+            <Trans>Delete</Trans>
+          </ButtonText>
+          {isProcessing && <Loader size="xs" color="white" />}
+        </Button>
+        <Button
+          variant="solid"
+          color="secondary"
+          label={_(msg`Return to previous page`)}
+          size="large"
+          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}
+          disabled={isProcessing}
+          onPress={goBack}>
+          <ButtonText>
+            <Trans>Go Back</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </CenteredView>
+  )
+}
diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx
new file mode 100644
index 000000000..ea9bbf9d3
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/State.tsx
@@ -0,0 +1,163 @@
+import React from 'react'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
+} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+
+import {useSession} from 'state/session'
+import * as Toast from '#/view/com/util/Toast'
+
+const steps = ['Details', 'Profiles', 'Feeds'] as const
+type Step = (typeof steps)[number]
+
+type Action =
+  | {type: 'Next'}
+  | {type: 'Back'}
+  | {type: 'SetCanNext'; canNext: boolean}
+  | {type: 'SetName'; name: string}
+  | {type: 'SetDescription'; description: string}
+  | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic}
+  | {type: 'RemoveProfile'; profileDid: string}
+  | {type: 'AddFeed'; feed: GeneratorView}
+  | {type: 'RemoveFeed'; feedUri: string}
+  | {type: 'SetProcessing'; processing: boolean}
+  | {type: 'SetError'; error: string}
+
+interface State {
+  canNext: boolean
+  currentStep: Step
+  name?: string
+  description?: string
+  profiles: AppBskyActorDefs.ProfileViewBasic[]
+  feeds: GeneratorView[]
+  processing: boolean
+  error?: string
+  transitionDirection: 'Backward' | 'Forward'
+}
+
+type TStateContext = [State, (action: Action) => void]
+
+const StateContext = React.createContext<TStateContext>([
+  {} as State,
+  (_: Action) => {},
+])
+export const useWizardState = () => React.useContext(StateContext)
+
+function reducer(state: State, action: Action): State {
+  let updatedState = state
+
+  // -- Navigation
+  const currentIndex = steps.indexOf(state.currentStep)
+  if (action.type === 'Next' && state.currentStep !== 'Feeds') {
+    updatedState = {
+      ...state,
+      currentStep: steps[currentIndex + 1],
+      transitionDirection: 'Forward',
+    }
+  } else if (action.type === 'Back' && state.currentStep !== 'Details') {
+    updatedState = {
+      ...state,
+      currentStep: steps[currentIndex - 1],
+      transitionDirection: 'Backward',
+    }
+  }
+
+  switch (action.type) {
+    case 'SetName':
+      updatedState = {...state, name: action.name.slice(0, 50)}
+      break
+    case 'SetDescription':
+      updatedState = {...state, description: action.description}
+      break
+    case 'AddProfile':
+      if (state.profiles.length >= 51) {
+        Toast.show(msg`You may only add up to 50 profiles`.message ?? '')
+      } else {
+        updatedState = {...state, profiles: [...state.profiles, action.profile]}
+      }
+      break
+    case 'RemoveProfile':
+      updatedState = {
+        ...state,
+        profiles: state.profiles.filter(
+          profile => profile.did !== action.profileDid,
+        ),
+      }
+      break
+    case 'AddFeed':
+      if (state.feeds.length >= 50) {
+        Toast.show(msg`You may only add up to 50 feeds`.message ?? '')
+      } else {
+        updatedState = {...state, feeds: [...state.feeds, action.feed]}
+      }
+      break
+    case 'RemoveFeed':
+      updatedState = {
+        ...state,
+        feeds: state.feeds.filter(f => f.uri !== action.feedUri),
+      }
+      break
+    case 'SetProcessing':
+      updatedState = {...state, processing: action.processing}
+      break
+  }
+
+  return updatedState
+}
+
+// TODO supply the initial state to this component
+export function Provider({
+  starterPack,
+  listItems,
+  children,
+}: {
+  starterPack?: AppBskyGraphDefs.StarterPackView
+  listItems?: AppBskyGraphDefs.ListItemView[]
+  children: React.ReactNode
+}) {
+  const {currentAccount} = useSession()
+
+  const createInitialState = (): State => {
+    if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+      return {
+        canNext: true,
+        currentStep: 'Details',
+        name: starterPack.record.name,
+        description: starterPack.record.description,
+        profiles:
+          listItems
+            ?.map(i => i.subject)
+            .filter(p => p.did !== currentAccount?.did) ?? [],
+        feeds: starterPack.feeds ?? [],
+        processing: false,
+        transitionDirection: 'Forward',
+      }
+    }
+
+    return {
+      canNext: true,
+      currentStep: 'Details',
+      profiles: [],
+      feeds: [],
+      processing: false,
+      transitionDirection: 'Forward',
+    }
+  }
+
+  const [state, dispatch] = React.useReducer(reducer, null, createInitialState)
+
+  return (
+    <StateContext.Provider value={[state, dispatch]}>
+      {children}
+    </StateContext.Provider>
+  )
+}
+
+export {
+  type Action as WizardAction,
+  type State as WizardState,
+  type Step as WizardStep,
+}
diff --git a/src/screens/StarterPack/Wizard/StepDetails.tsx b/src/screens/StarterPack/Wizard/StepDetails.tsx
new file mode 100644
index 000000000..24c992c60
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepDetails.tsx
@@ -0,0 +1,84 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useProfileQuery} from 'state/queries/profile'
+import {useSession} from 'state/session'
+import {useWizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
+import {Text} from '#/components/Typography'
+
+export function StepDetails() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+
+  const {currentAccount} = useSession()
+  const {data: currentProfile} = useProfileQuery({
+    did: currentAccount?.did,
+    staleTime: 300,
+  })
+
+  return (
+    <ScreenTransition direction={state.transitionDirection}>
+      <View style={[a.px_xl, a.gap_xl, a.mt_4xl]}>
+        <View style={[a.gap_md, a.align_center, a.px_md, a.mb_md]}>
+          <StarterPack width={90} gradient="sky" />
+          <Text style={[a.font_bold, a.text_3xl]}>
+            <Trans>Invites, but personal</Trans>
+          </Text>
+          <Text style={[a.text_center, a.text_md, a.px_md]}>
+            <Trans>
+              Invite your friends to follow your favorite feeds and people
+            </Trans>
+          </Text>
+        </View>
+        <View>
+          <TextField.LabelText>
+            <Trans>What do you want to call your starter pack?</Trans>
+          </TextField.LabelText>
+          <TextField.Root>
+            <TextField.Input
+              label={_(
+                msg`${
+                  currentProfile?.displayName || currentProfile?.handle
+                }'s starter pack`,
+              )}
+              value={state.name}
+              onChangeText={text => dispatch({type: 'SetName', name: text})}
+            />
+            <TextField.SuffixText label={_(`${state.name?.length} out of 50`)}>
+              <Text style={[t.atoms.text_contrast_medium]}>
+                {state.name?.length ?? 0}/50
+              </Text>
+            </TextField.SuffixText>
+          </TextField.Root>
+        </View>
+        <View>
+          <TextField.LabelText>
+            <Trans>Tell us a little more</Trans>
+          </TextField.LabelText>
+          <TextField.Root>
+            <TextField.Input
+              label={_(
+                msg`${
+                  currentProfile?.displayName || currentProfile?.handle
+                }'s favorite feeds and people - join me!`,
+              )}
+              value={state.description}
+              onChangeText={text =>
+                dispatch({type: 'SetDescription', description: text})
+              }
+              multiline
+              style={{minHeight: 150}}
+            />
+          </TextField.Root>
+        </View>
+      </View>
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx
new file mode 100644
index 000000000..6752a95db
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx
@@ -0,0 +1,113 @@
+import React, {useState} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
+import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {useA11y} from '#/state/a11y'
+import {DISCOVER_FEED_URI} from 'lib/constants'
+import {
+  useGetPopularFeedsQuery,
+  useSavedFeeds,
+  useSearchPopularFeedsQuery,
+} from 'state/queries/feed'
+import {SearchInput} from 'view/com/util/forms/SearchInput'
+import {List} from 'view/com/util/List'
+import {useWizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
+import {Loader} from '#/components/Loader'
+import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
+import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
+  return item.uri
+}
+
+export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+  const [query, setQuery] = useState('')
+  const throttledQuery = useThrottledValue(query, 500)
+  const {screenReaderEnabled} = useA11y()
+
+  const {data: savedFeedsAndLists} = useSavedFeeds()
+  const savedFeeds = savedFeedsAndLists?.feeds
+    .filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI)
+    .map(f => f.view) as AppBskyFeedDefs.GeneratorView[]
+
+  const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery({
+    limit: 30,
+  })
+  const popularFeeds =
+    popularFeedsPages?.pages
+      .flatMap(page => page.feeds)
+      .filter(f => !savedFeeds?.some(sf => sf?.uri === f.uri)) ?? []
+
+  const suggestedFeeds = savedFeeds?.concat(popularFeeds)
+
+  const {data: searchedFeeds, isLoading: isLoadingSearch} =
+    useSearchPopularFeedsQuery({q: throttledQuery})
+
+  const renderItem = ({
+    item,
+  }: ListRenderItemInfo<AppBskyFeedDefs.GeneratorView>) => {
+    return (
+      <WizardFeedCard
+        generator={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    )
+  }
+
+  return (
+    <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
+      <View style={[a.border_b, t.atoms.border_contrast_medium]}>
+        <View style={[a.my_sm, a.px_md, {height: 40}]}>
+          <SearchInput
+            query={query}
+            onChangeQuery={t => setQuery(t)}
+            onPressCancelSearch={() => setQuery('')}
+            onSubmitQuery={() => {}}
+          />
+        </View>
+      </View>
+      <List
+        data={query ? searchedFeeds : suggestedFeeds}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        contentContainerStyle={{paddingTop: 6}}
+        onEndReached={
+          !query && !screenReaderEnabled ? () => fetchNextPage() : undefined
+        }
+        onEndReachedThreshold={2}
+        renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
+        keyboardShouldPersistTaps="handled"
+        containWeb={true}
+        sideBorders={false}
+        style={{flex: 1}}
+        ListEmptyComponent={
+          <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}>
+            {isLoadingSearch ? (
+              <Loader size="lg" />
+            ) : (
+              <Text
+                style={[
+                  a.font_bold,
+                  a.text_lg,
+                  a.text_center,
+                  a.mt_lg,
+                  a.leading_snug,
+                ]}>
+                <Trans>No feeds found. Try searching for something else.</Trans>
+              </Text>
+            )}
+          </View>
+        }
+      />
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/StarterPack/Wizard/StepFinished.tsx b/src/screens/StarterPack/Wizard/StepFinished.tsx
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepFinished.tsx
diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx
new file mode 100644
index 000000000..8fe7f52fe
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx
@@ -0,0 +1,101 @@
+import React, {useState} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
+import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {useA11y} from '#/state/a11y'
+import {isNative} from 'platform/detection'
+import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
+import {useActorSearchPaginated} from 'state/queries/actor-search'
+import {SearchInput} from 'view/com/util/forms/SearchInput'
+import {List} from 'view/com/util/List'
+import {useWizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
+import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
+  return item?.did ?? ''
+}
+
+export function StepProfiles({
+  moderationOpts,
+}: {
+  moderationOpts: ModerationOpts
+}) {
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+  const [query, setQuery] = useState('')
+  const {screenReaderEnabled} = useA11y()
+
+  const {data: topPages, fetchNextPage} = useActorSearchPaginated({
+    query: encodeURIComponent('*'),
+  })
+  const topFollowers = topPages?.pages.flatMap(p => p.actors)
+
+  const {data: results, isLoading: isLoadingResults} =
+    useActorAutocompleteQuery(query, true, 12)
+
+  const renderItem = ({
+    item,
+  }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
+    return (
+      <WizardProfileCard
+        profile={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    )
+  }
+
+  return (
+    <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
+      <View style={[a.border_b, t.atoms.border_contrast_medium]}>
+        <View style={[a.my_sm, a.px_md, {height: 40}]}>
+          <SearchInput
+            query={query}
+            onChangeQuery={setQuery}
+            onPressCancelSearch={() => setQuery('')}
+            onSubmitQuery={() => {}}
+          />
+        </View>
+      </View>
+      <List
+        data={query ? results : topFollowers}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
+        keyboardShouldPersistTaps="handled"
+        containWeb={true}
+        sideBorders={false}
+        style={[a.flex_1]}
+        onEndReached={
+          !query && !screenReaderEnabled ? () => fetchNextPage() : undefined
+        }
+        onEndReachedThreshold={isNative ? 2 : 0.25}
+        ListEmptyComponent={
+          <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}>
+            {isLoadingResults ? (
+              <Loader size="lg" />
+            ) : (
+              <Text
+                style={[
+                  a.font_bold,
+                  a.text_lg,
+                  a.text_center,
+                  a.mt_lg,
+                  a.leading_snug,
+                ]}>
+                <Trans>Nobody was found. Try searching for someone else.</Trans>
+              </Text>
+            )}
+          </View>
+        }
+      />
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx
new file mode 100644
index 000000000..76691dc98
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/index.tsx
@@ -0,0 +1,575 @@
+import React from 'react'
+import {Keyboard, TouchableOpacity, View} from 'react-native'
+import {
+  KeyboardAwareScrollView,
+  useKeyboardController,
+} from 'react-native-keyboard-controller'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {Image} from 'expo-image'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphDefs,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {logger} from '#/logger'
+import {HITSLOP_10} from 'lib/constants'
+import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
+import {logEvent} from 'lib/statsig/statsig'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {enforceLen} from 'lib/strings/helpers'
+import {
+  getStarterPackOgCard,
+  parseStarterPackUri,
+} from 'lib/strings/starter-pack'
+import {isAndroid, isNative, isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
+import {useListMembersQuery} from 'state/queries/list-members'
+import {useProfileQuery} from 'state/queries/profile'
+import {
+  useCreateStarterPackMutation,
+  useEditStarterPackMutation,
+  useStarterPackQuery,
+} from 'state/queries/starter-packs'
+import {useSession} from 'state/session'
+import {useSetMinimalShellMode} from 'state/shell'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {CenteredView} from 'view/com/util/Views'
+import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State'
+import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails'
+import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds'
+import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {ListMaybePlaceholder} from '#/components/Lists'
+import {Loader} from '#/components/Loader'
+import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog'
+import {Text} from '#/components/Typography'
+import {Provider} from './State'
+
+export function Wizard({
+  route,
+}: NativeStackScreenProps<
+  CommonNavigatorParams,
+  'StarterPackEdit' | 'StarterPackWizard'
+>) {
+  const {rkey} = route.params ?? {}
+  const {currentAccount} = useSession()
+  const moderationOpts = useModerationOpts()
+
+  const {_} = useLingui()
+
+  const {
+    data: starterPack,
+    isLoading: isLoadingStarterPack,
+    isError: isErrorStarterPack,
+  } = useStarterPackQuery({did: currentAccount!.did, rkey})
+  const listUri = starterPack?.list?.uri
+
+  const {
+    data: profilesData,
+    isLoading: isLoadingProfiles,
+    isError: isErrorProfiles,
+  } = useListMembersQuery(listUri, 50)
+  const listItems = profilesData?.pages.flatMap(p => p.items)
+
+  const {
+    data: profile,
+    isLoading: isLoadingProfile,
+    isError: isErrorProfile,
+  } = useProfileQuery({did: currentAccount?.did})
+
+  const isEdit = Boolean(rkey)
+  const isReady =
+    (!isEdit || (isEdit && starterPack && listItems)) &&
+    profile &&
+    moderationOpts
+
+  if (!isReady) {
+    return (
+      <ListMaybePlaceholder
+        isLoading={
+          isLoadingStarterPack || isLoadingProfiles || isLoadingProfile
+        }
+        isError={isErrorStarterPack || isErrorProfiles || isErrorProfile}
+        errorMessage={_(msg`That starter pack could not be found.`)}
+      />
+    )
+  } else if (isEdit && starterPack?.creator.did !== currentAccount?.did) {
+    return (
+      <ListMaybePlaceholder
+        isLoading={false}
+        isError={true}
+        errorMessage={_(msg`That starter pack could not be found.`)}
+      />
+    )
+  }
+
+  return (
+    <Provider starterPack={starterPack} listItems={listItems}>
+      <WizardInner
+        currentStarterPack={starterPack}
+        currentListItems={listItems}
+        profile={profile}
+        moderationOpts={moderationOpts}
+      />
+    </Provider>
+  )
+}
+
+function WizardInner({
+  currentStarterPack,
+  currentListItems,
+  profile,
+  moderationOpts,
+}: {
+  currentStarterPack?: AppBskyGraphDefs.StarterPackView
+  currentListItems?: AppBskyGraphDefs.ListItemView[]
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderationOpts: ModerationOpts
+}) {
+  const navigation = useNavigation<NavigationProp>()
+  const {_} = useLingui()
+  const t = useTheme()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {setEnabled} = useKeyboardController()
+  const [state, dispatch] = useWizardState()
+  const {currentAccount} = useSession()
+  const {data: currentProfile} = useProfileQuery({
+    did: currentAccount?.did,
+    staleTime: 0,
+  })
+  const parsed = parseStarterPackUri(currentStarterPack?.uri)
+
+  React.useEffect(() => {
+    navigation.setOptions({
+      gestureEnabled: false,
+    })
+  }, [navigation])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setEnabled(true)
+      setMinimalShellMode(true)
+
+      return () => {
+        setMinimalShellMode(false)
+        setEnabled(false)
+      }
+    }, [setMinimalShellMode, setEnabled]),
+  )
+
+  const getDefaultName = () => {
+    let displayName
+    if (
+      currentProfile?.displayName != null &&
+      currentProfile?.displayName !== ''
+    ) {
+      displayName = sanitizeDisplayName(currentProfile.displayName)
+    } else {
+      displayName = sanitizeHandle(currentProfile!.handle)
+    }
+    return _(msg`${displayName}'s Starter Pack`).slice(0, 50)
+  }
+
+  const wizardUiStrings: Record<
+    WizardStep,
+    {header: string; nextBtn: string; subtitle?: string}
+  > = {
+    Details: {
+      header: _(msg`Starter Pack`),
+      nextBtn: _(msg`Next`),
+    },
+    Profiles: {
+      header: _(msg`People`),
+      nextBtn: _(msg`Next`),
+      subtitle: _(
+        msg`Add people to your starter pack that you think others will enjoy following`,
+      ),
+    },
+    Feeds: {
+      header: _(msg`Feeds`),
+      nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`),
+      subtitle: _(msg`Some subtitle`),
+    },
+  }
+  const currUiStrings = wizardUiStrings[state.currentStep]
+
+  const onSuccessCreate = (data: {uri: string; cid: string}) => {
+    const rkey = new AtUri(data.uri).rkey
+    logEvent('starterPack:create', {
+      setName: state.name != null,
+      setDescription: state.description != null,
+      profilesCount: state.profiles.length,
+      feedsCount: state.feeds.length,
+    })
+    Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)])
+    dispatch({type: 'SetProcessing', processing: false})
+    navigation.replace('StarterPack', {
+      name: currentAccount!.handle,
+      rkey,
+      new: true,
+    })
+  }
+
+  const onSuccessEdit = () => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.replace('StarterPack', {
+        name: currentAccount!.handle,
+        rkey: parsed!.rkey,
+      })
+    }
+  }
+
+  const {mutate: createStarterPack} = useCreateStarterPackMutation({
+    onSuccess: onSuccessCreate,
+    onError: e => {
+      logger.error('Failed to create starter pack', {safeMessage: e})
+      dispatch({type: 'SetProcessing', processing: false})
+      Toast.show(_(msg`Failed to create starter pack`))
+    },
+  })
+  const {mutate: editStarterPack} = useEditStarterPackMutation({
+    onSuccess: onSuccessEdit,
+    onError: e => {
+      logger.error('Failed to edit starter pack', {safeMessage: e})
+      dispatch({type: 'SetProcessing', processing: false})
+      Toast.show(_(msg`Failed to create starter pack`))
+    },
+  })
+
+  const submit = async () => {
+    dispatch({type: 'SetProcessing', processing: true})
+    if (currentStarterPack && currentListItems) {
+      editStarterPack({
+        name: state.name ?? getDefaultName(),
+        description: state.description,
+        descriptionFacets: [],
+        profiles: state.profiles,
+        feeds: state.feeds,
+        currentStarterPack: currentStarterPack,
+        currentListItems: currentListItems,
+      })
+    } else {
+      createStarterPack({
+        name: state.name ?? getDefaultName(),
+        description: state.description,
+        descriptionFacets: [],
+        profiles: state.profiles,
+        feeds: state.feeds,
+      })
+    }
+  }
+
+  const onNext = () => {
+    if (state.currentStep === 'Feeds') {
+      submit()
+      return
+    }
+
+    const keyboardVisible = Keyboard.isVisible()
+    Keyboard.dismiss()
+    setTimeout(
+      () => {
+        dispatch({type: 'Next'})
+      },
+      keyboardVisible ? 16 : 0,
+    )
+  }
+
+  return (
+    <CenteredView style={[a.flex_1]} sideBorders>
+      <View
+        style={[
+          a.flex_row,
+          a.pb_sm,
+          a.px_md,
+          a.border_b,
+          t.atoms.border_contrast_medium,
+          a.gap_sm,
+          a.justify_between,
+          a.align_center,
+          isAndroid && a.pt_sm,
+          isWeb && [a.py_md],
+        ]}>
+        <View style={[{width: 65}]}>
+          <TouchableOpacity
+            testID="viewHeaderDrawerBtn"
+            hitSlop={HITSLOP_10}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Back`)}
+            accessibilityHint={_(msg`Go back to the previous step`)}
+            onPress={() => {
+              if (state.currentStep === 'Details') {
+                navigation.pop()
+              } else {
+                dispatch({type: 'Back'})
+              }
+            }}>
+            <FontAwesomeIcon
+              size={18}
+              icon="angle-left"
+              color={t.atoms.text.color}
+            />
+          </TouchableOpacity>
+        </View>
+        <Text style={[a.flex_1, a.font_bold, a.text_lg, a.text_center]}>
+          {currUiStrings.header}
+        </Text>
+        <View style={[{width: 65}]} />
+      </View>
+
+      <Container>
+        {state.currentStep === 'Details' ? (
+          <StepDetails />
+        ) : state.currentStep === 'Profiles' ? (
+          <StepProfiles moderationOpts={moderationOpts} />
+        ) : state.currentStep === 'Feeds' ? (
+          <StepFeeds moderationOpts={moderationOpts} />
+        ) : null}
+      </Container>
+
+      {state.currentStep !== 'Details' && (
+        <Footer
+          onNext={onNext}
+          nextBtnText={currUiStrings.nextBtn}
+          moderationOpts={moderationOpts}
+          profile={profile}
+        />
+      )}
+    </CenteredView>
+  )
+}
+
+function Container({children}: {children: React.ReactNode}) {
+  const {_} = useLingui()
+  const [state, dispatch] = useWizardState()
+
+  if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') {
+    return <View style={[a.flex_1]}>{children}</View>
+  }
+
+  return (
+    <KeyboardAwareScrollView
+      style={[a.flex_1]}
+      keyboardShouldPersistTaps="handled">
+      {children}
+      {state.currentStep === 'Details' && (
+        <>
+          <Button
+            label={_(msg`Next`)}
+            variant="solid"
+            color="primary"
+            size="medium"
+            style={[a.mx_xl, a.mb_lg, {marginTop: 35}]}
+            onPress={() => dispatch({type: 'Next'})}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+          </Button>
+        </>
+      )}
+    </KeyboardAwareScrollView>
+  )
+}
+
+function Footer({
+  onNext,
+  nextBtnText,
+  moderationOpts,
+  profile,
+}: {
+  onNext: () => void
+  nextBtnText: string
+  moderationOpts: ModerationOpts
+  profile: AppBskyActorDefs.ProfileViewBasic
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+  const editDialogControl = useDialogControl()
+  const {bottom: bottomInset} = useSafeAreaInsets()
+
+  const items =
+    state.currentStep === 'Profiles'
+      ? [profile, ...state.profiles]
+      : state.feeds
+  const initialNamesIndex = state.currentStep === 'Profiles' ? 1 : 0
+
+  const isEditEnabled =
+    (state.currentStep === 'Profiles' && items.length > 1) ||
+    (state.currentStep === 'Feeds' && items.length > 0)
+
+  const minimumItems = state.currentStep === 'Profiles' ? 8 : 0
+
+  const textStyles = [a.text_md]
+
+  return (
+    <View
+      style={[
+        a.border_t,
+        a.align_center,
+        a.px_lg,
+        a.pt_xl,
+        a.gap_md,
+        t.atoms.bg,
+        t.atoms.border_contrast_medium,
+        {
+          paddingBottom: a.pb_lg.paddingBottom + bottomInset,
+        },
+        isNative && [
+          a.border_l,
+          a.border_r,
+          t.atoms.shadow_md,
+          {
+            borderTopLeftRadius: 14,
+            borderTopRightRadius: 14,
+          },
+        ],
+      ]}>
+      {items.length > minimumItems && (
+        <View style={[a.absolute, {right: 14, top: 31}]}>
+          <Text style={[a.font_bold]}>
+            {items.length}/{state.currentStep === 'Profiles' ? 50 : 3}
+          </Text>
+        </View>
+      )}
+
+      <View style={[a.flex_row, a.gap_xs]}>
+        {items.slice(0, 6).map((p, index) => (
+          <UserAvatar
+            key={index}
+            avatar={p.avatar}
+            size={32}
+            type={state.currentStep === 'Profiles' ? 'user' : 'algo'}
+          />
+        ))}
+      </View>
+
+      {items.length === 0 ? (
+        <View style={[a.gap_sm]}>
+          <Text style={[a.font_bold, a.text_center, textStyles]}>
+            <Trans>Add some feeds to your starter pack!</Trans>
+          </Text>
+          <Text style={[a.text_center, textStyles]}>
+            <Trans>Search for feeds that you want to suggest to others.</Trans>
+          </Text>
+        </View>
+      ) : (
+        <Text style={[a.text_center, textStyles]}>
+          {state.currentStep === 'Profiles' && items.length === 1 ? (
+            <Trans>
+              It's just you right now! Add more people to your starter pack by
+              searching above.
+            </Trans>
+          ) : items.length === 1 ? (
+            <Trans>
+              <Text style={[a.font_bold, textStyles]}>
+                {getName(items[initialNamesIndex])}
+              </Text>{' '}
+              is included in your starter pack
+            </Trans>
+          ) : items.length === 2 ? (
+            <Trans>
+              <Text style={[a.font_bold, textStyles]}>
+                {getName(items[initialNamesIndex])}{' '}
+              </Text>
+              and
+              <Text> </Text>
+              <Text style={[a.font_bold, textStyles]}>
+                {getName(items[state.currentStep === 'Profiles' ? 0 : 1])}{' '}
+              </Text>
+              are included in your starter pack
+            </Trans>
+          ) : (
+            <Trans>
+              <Text style={[a.font_bold, textStyles]}>
+                {getName(items[initialNamesIndex])},{' '}
+              </Text>
+              <Text style={[a.font_bold, textStyles]}>
+                {getName(items[initialNamesIndex + 1])},{' '}
+              </Text>
+              and {items.length - 2}{' '}
+              <Plural value={items.length - 2} one="other" other="others" /> are
+              included in your starter pack
+            </Trans>
+          )}
+        </Text>
+      )}
+
+      <View
+        style={[
+          a.flex_row,
+          a.w_full,
+          a.justify_between,
+          a.align_center,
+          isNative ? a.mt_sm : a.mt_md,
+        ]}>
+        {isEditEnabled ? (
+          <Button
+            label={_(msg`Edit`)}
+            variant="solid"
+            color="secondary"
+            size="small"
+            style={{width: 70}}
+            onPress={editDialogControl.open}>
+            <ButtonText>
+              <Trans>Edit</Trans>
+            </ButtonText>
+          </Button>
+        ) : (
+          <View style={{width: 70, height: 35}} />
+        )}
+        {state.currentStep === 'Profiles' && items.length < 8 ? (
+          <>
+            <Text
+              style={[a.font_bold, textStyles, t.atoms.text_contrast_medium]}>
+              <Trans>Add {8 - items.length} more to continue</Trans>
+            </Text>
+            <View style={{width: 70}} />
+          </>
+        ) : (
+          <Button
+            label={nextBtnText}
+            variant="solid"
+            color="primary"
+            size="small"
+            onPress={onNext}
+            disabled={!state.canNext || state.processing}>
+            <ButtonText>{nextBtnText}</ButtonText>
+            {state.processing && <Loader size="xs" style={{color: 'white'}} />}
+          </Button>
+        )}
+      </View>
+
+      <WizardEditListDialog
+        control={editDialogControl}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+        profile={profile}
+      />
+    </View>
+  )
+}
+
+function getName(item: AppBskyActorDefs.ProfileViewBasic | GeneratorView) {
+  if (typeof item.displayName === 'string') {
+    return enforceLen(sanitizeDisplayName(item.displayName), 16, true)
+  } else if (typeof item.handle === 'string') {
+    return enforceLen(sanitizeHandle(item.handle), 16, true)
+  }
+  return ''
+}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index c942828f2..88fc370a6 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -88,6 +88,7 @@ export const schema = z.object({
   disableHaptics: z.boolean().optional(),
   disableAutoplay: z.boolean().optional(),
   kawaii: z.boolean().optional(),
+  hasCheckedForStarterPack: z.boolean().optional(),
   /** @deprecated */
   mutedThreads: z.array(z.string()),
 })
@@ -129,4 +130,5 @@ export const defaults: Schema = {
   disableHaptics: false,
   disableAutoplay: prefersReducedMotion,
   kawaii: false,
+  hasCheckedForStarterPack: false,
 }
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index e1a35f193..e6b53d5be 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
 import {Provider as KawaiiProvider} from './kawaii'
 import {Provider as LanguagesProvider} from './languages'
 import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
+import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
 
 export {
   useRequireAltTextEnabled,
@@ -34,7 +35,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
               <InAppBrowserProvider>
                 <DisableHapticsProvider>
                   <AutoplayProvider>
-                    <KawaiiProvider>{children}</KawaiiProvider>
+                    <UsedStarterPacksProvider>
+                      <KawaiiProvider>{children}</KawaiiProvider>
+                    </UsedStarterPacksProvider>
                   </AutoplayProvider>
                 </DisableHapticsProvider>
               </InAppBrowserProvider>
diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx
new file mode 100644
index 000000000..8d5d9e828
--- /dev/null
+++ b/src/state/preferences/used-starter-packs.tsx
@@ -0,0 +1,37 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = boolean | undefined
+type SetContext = (v: boolean) => void
+
+const stateContext = React.createContext<StateContext>(false)
+const setContext = React.createContext<SetContext>((_: boolean) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [state, setState] = React.useState<StateContext>(() =>
+    persisted.get('hasCheckedForStarterPack'),
+  )
+
+  const setStateWrapped = (v: boolean) => {
+    setState(v)
+    persisted.write('hasCheckedForStarterPack', v)
+  }
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('hasCheckedForStarterPack'))
+    })
+  }, [])
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export const useHasCheckedForStarterPack = () => React.useContext(stateContext)
+export const useSetHasCheckedForStarterPack = () => React.useContext(setContext)
diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts
index 1e301a1ba..479fc1a9f 100644
--- a/src/state/queries/actor-search.ts
+++ b/src/state/queries/actor-search.ts
@@ -1,5 +1,11 @@
-import {AppBskyActorDefs} from '@atproto/api'
-import {QueryClient, useQuery} from '@tanstack/react-query'
+import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
+import {
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+  useInfiniteQuery,
+  useQuery,
+} from '@tanstack/react-query'
 
 import {STALE} from '#/state/queries'
 import {useAgent} from '#/state/session'
@@ -7,6 +13,11 @@ import {useAgent} from '#/state/session'
 const RQKEY_ROOT = 'actor-search'
 export const RQKEY = (query: string) => [RQKEY_ROOT, query]
 
+export const RQKEY_PAGINATED = (query: string) => [
+  `${RQKEY_ROOT}_paginated`,
+  query,
+]
+
 export function useActorSearch({
   query,
   enabled,
@@ -28,6 +39,37 @@ export function useActorSearch({
   })
 }
 
+export function useActorSearchPaginated({
+  query,
+  enabled,
+}: {
+  query: string
+  enabled?: boolean
+}) {
+  const agent = useAgent()
+  return useInfiniteQuery<
+    AppBskyActorSearchActors.OutputSchema,
+    Error,
+    InfiniteData<AppBskyActorSearchActors.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    staleTime: STALE.MINUTES.FIVE,
+    queryKey: RQKEY_PAGINATED(query),
+    queryFn: async ({pageParam}) => {
+      const res = await agent.searchActors({
+        q: query,
+        limit: 25,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    enabled: enabled && !!query,
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
 export function* findAllProfilesInQueryData(
   queryClient: QueryClient,
   did: string,
diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts
new file mode 100644
index 000000000..9de80b07d
--- /dev/null
+++ b/src/state/queries/actor-starter-packs.ts
@@ -0,0 +1,47 @@
+import {AppBskyGraphGetActorStarterPacks} from '@atproto/api'
+import {
+  InfiniteData,
+  QueryClient,
+  QueryKey,
+  useInfiniteQuery,
+} from '@tanstack/react-query'
+
+import {useAgent} from 'state/session'
+
+const RQKEY_ROOT = 'actor-starter-packs'
+export const RQKEY = (did?: string) => [RQKEY_ROOT, did]
+
+export function useActorStarterPacksQuery({did}: {did?: string}) {
+  const agent = useAgent()
+
+  return useInfiniteQuery<
+    AppBskyGraphGetActorStarterPacks.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: RQKEY(did),
+    queryFn: async ({pageParam}: {pageParam?: string}) => {
+      const res = await agent.app.bsky.graph.getActorStarterPacks({
+        actor: did!,
+        limit: 10,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    enabled: Boolean(did),
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
+export async function invalidateActorStarterPacksQuery({
+  queryClient,
+  did,
+}: {
+  queryClient: QueryClient
+  did: string
+}) {
+  await queryClient.invalidateQueries({queryKey: RQKEY(did)})
+}
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index e5d615177..dea6f5d77 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -9,6 +9,7 @@ import {
 } from '@atproto/api'
 import {
   InfiniteData,
+  keepPreviousData,
   QueryClient,
   QueryKey,
   useInfiniteQuery,
@@ -315,6 +316,22 @@ export function useSearchPopularFeedsMutation() {
   })
 }
 
+export function useSearchPopularFeedsQuery({q}: {q: string}) {
+  const agent = useAgent()
+  return useQuery({
+    queryKey: ['searchPopularFeeds', q],
+    queryFn: async () => {
+      const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
+        limit: 15,
+        query: q,
+      })
+
+      return res.data.feeds
+    },
+    placeholderData: keepPreviousData,
+  })
+}
+
 const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
 export const createPopularFeedsSearchQueryKey = (query: string) => [
   popularFeedsSearchQueryKeyRoot,
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
index de9a36ab7..3131a2ec3 100644
--- a/src/state/queries/list-members.ts
+++ b/src/state/queries/list-members.ts
@@ -15,7 +15,7 @@ type RQPageParam = string | undefined
 const RQKEY_ROOT = 'list-members'
 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
 
-export function useListMembersQuery(uri: string) {
+export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) {
   const agent = useAgent()
   return useInfiniteQuery<
     AppBskyGraphGetList.OutputSchema,
@@ -25,20 +25,31 @@ export function useListMembersQuery(uri: string) {
     RQPageParam
   >({
     staleTime: STALE.MINUTES.ONE,
-    queryKey: RQKEY(uri),
+    queryKey: RQKEY(uri ?? ''),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await agent.app.bsky.graph.getList({
-        list: uri,
-        limit: PAGE_SIZE,
+        list: uri!, // the enabled flag will prevent this from running until uri is set
+        limit,
         cursor: pageParam,
       })
       return res.data
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
+    enabled: Boolean(uri),
   })
 }
 
+export async function invalidateListMembersQuery({
+  queryClient,
+  uri,
+}: {
+  queryClient: QueryClient
+  uri: string
+}) {
+  await queryClient.invalidateQueries({queryKey: RQKEY(uri)})
+}
+
 export function* findAllProfilesInQueryData(
   queryClient: QueryClient,
   did: string,
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 0607f07a1..13ca3ffde 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -155,8 +155,10 @@ export function* findAllPostsInQueryData(
 
     for (const page of queryData?.pages) {
       for (const item of page.items) {
-        if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
-          yield item.subject
+        if (item.type !== 'starterpack-joined') {
+          if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
+            yield item.subject
+          }
         }
 
         const quotedPost = getEmbeddedPost(item.subject?.embed)
@@ -181,7 +183,10 @@ export function* findAllProfilesInQueryData(
     }
     for (const page of queryData?.pages) {
       for (const item of page.items) {
-        if (item.subject?.author.did === did) {
+        if (
+          item.type !== 'starterpack-joined' &&
+          item.subject?.author.did === did
+        ) {
           yield item.subject.author
         }
         const quotedPost = getEmbeddedPost(item.subject?.embed)
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
index 812236cf0..d40a07b12 100644
--- a/src/state/queries/notifications/types.ts
+++ b/src/state/queries/notifications/types.ts
@@ -1,26 +1,22 @@
 import {
-  AppBskyNotificationListNotifications,
   AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AppBskyNotificationListNotifications,
 } from '@atproto/api'
 
 export type NotificationType =
-  | 'post-like'
-  | 'feedgen-like'
-  | 'repost'
-  | 'mention'
-  | 'reply'
-  | 'quote'
-  | 'follow'
-  | 'unknown'
+  | StarterPackNotificationType
+  | OtherNotificationType
 
-export interface FeedNotification {
-  _reactKey: string
-  type: NotificationType
-  notification: AppBskyNotificationListNotifications.Notification
-  additional?: AppBskyNotificationListNotifications.Notification[]
-  subjectUri?: string
-  subject?: AppBskyFeedDefs.PostView
-}
+export type FeedNotification =
+  | (FeedNotificationBase & {
+      type: StarterPackNotificationType
+      subject?: AppBskyGraphDefs.StarterPackViewBasic
+    })
+  | (FeedNotificationBase & {
+      type: OtherNotificationType
+      subject?: AppBskyFeedDefs.PostView
+    })
 
 export interface FeedPage {
   cursor: string | undefined
@@ -37,3 +33,22 @@ export interface CachedFeedPage {
   data: FeedPage | undefined
   unreadCount: number
 }
+
+type StarterPackNotificationType = 'starterpack-joined'
+type OtherNotificationType =
+  | 'post-like'
+  | 'repost'
+  | 'mention'
+  | 'reply'
+  | 'quote'
+  | 'follow'
+  | 'feedgen-like'
+  | 'unknown'
+
+type FeedNotificationBase = {
+  _reactKey: string
+  notification: AppBskyNotificationListNotifications.Notification
+  additional?: AppBskyNotificationListNotifications.Notification[]
+  subjectUri?: string
+  subject?: AppBskyFeedDefs.PostView | AppBskyGraphDefs.StarterPackViewBasic
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 8ed1c0390..ade98b317 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -3,6 +3,8 @@ import {
   AppBskyFeedLike,
   AppBskyFeedPost,
   AppBskyFeedRepost,
+  AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
   AppBskyNotificationListNotifications,
   BskyAgent,
   moderateNotification,
@@ -40,6 +42,7 @@ export async function fetchPage({
     limit,
     cursor,
   })
+
   const indexedAt = res.data.notifications[0]?.indexedAt
 
   // filter out notifs by mod rules
@@ -56,9 +59,18 @@ export async function fetchPage({
     const subjects = await fetchSubjects(agent, notifsGrouped)
     for (const notif of notifsGrouped) {
       if (notif.subjectUri) {
-        notif.subject = subjects.get(notif.subjectUri)
-        if (notif.subject) {
-          precacheProfile(queryClient, notif.subject.author)
+        if (
+          notif.type === 'starterpack-joined' &&
+          notif.notification.reasonSubject
+        ) {
+          notif.subject = subjects.starterPacks.get(
+            notif.notification.reasonSubject,
+          )
+        } else {
+          notif.subject = subjects.posts.get(notif.subjectUri)
+          if (notif.subject) {
+            precacheProfile(queryClient, notif.subject.author)
+          }
         }
       }
     }
@@ -120,12 +132,21 @@ export function groupNotifications(
     }
     if (!grouped) {
       const type = toKnownType(notif)
-      groupedNotifs.push({
-        _reactKey: `notif-${notif.uri}`,
-        type,
-        notification: notif,
-        subjectUri: getSubjectUri(type, notif),
-      })
+      if (type !== 'starterpack-joined') {
+        groupedNotifs.push({
+          _reactKey: `notif-${notif.uri}`,
+          type,
+          notification: notif,
+          subjectUri: getSubjectUri(type, notif),
+        })
+      } else {
+        groupedNotifs.push({
+          _reactKey: `notif-${notif.uri}`,
+          type: 'starterpack-joined',
+          notification: notif,
+          subjectUri: notif.uri,
+        })
+      }
     }
   }
   return groupedNotifs
@@ -134,29 +155,54 @@ export function groupNotifications(
 async function fetchSubjects(
   agent: BskyAgent,
   groupedNotifs: FeedNotification[],
-): Promise<Map<string, AppBskyFeedDefs.PostView>> {
-  const uris = new Set<string>()
+): Promise<{
+  posts: Map<string, AppBskyFeedDefs.PostView>
+  starterPacks: Map<string, AppBskyGraphDefs.StarterPackViewBasic>
+}> {
+  const postUris = new Set<string>()
+  const packUris = new Set<string>()
   for (const notif of groupedNotifs) {
     if (notif.subjectUri?.includes('app.bsky.feed.post')) {
-      uris.add(notif.subjectUri)
+      postUris.add(notif.subjectUri)
+    } else if (
+      notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
+    ) {
+      packUris.add(notif.notification.reasonSubject)
     }
   }
-  const uriChunks = chunk(Array.from(uris), 25)
+  const postUriChunks = chunk(Array.from(postUris), 25)
+  const packUriChunks = chunk(Array.from(packUris), 25)
   const postsChunks = await Promise.all(
-    uriChunks.map(uris =>
+    postUriChunks.map(uris =>
       agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
     ),
   )
-  const map = new Map<string, AppBskyFeedDefs.PostView>()
+  const packsChunks = await Promise.all(
+    packUriChunks.map(uris =>
+      agent.app.bsky.graph
+        .getStarterPacks({uris})
+        .then(res => res.data.starterPacks),
+    ),
+  )
+  const postsMap = new Map<string, AppBskyFeedDefs.PostView>()
+  const packsMap = new Map<string, AppBskyGraphDefs.StarterPackView>()
   for (const post of postsChunks.flat()) {
     if (
       AppBskyFeedPost.isRecord(post.record) &&
       AppBskyFeedPost.validateRecord(post.record).success
     ) {
-      map.set(post.uri, post)
+      postsMap.set(post.uri, post)
+    }
+  }
+  for (const pack of packsChunks.flat()) {
+    if (AppBskyGraphStarterpack.isRecord(pack.record)) {
+      packsMap.set(pack.uri, pack)
     }
   }
-  return map
+  return {
+    posts: postsMap,
+    starterPacks: packsMap,
+  }
 }
 
 function toKnownType(
@@ -173,7 +219,8 @@ function toKnownType(
     notif.reason === 'mention' ||
     notif.reason === 'reply' ||
     notif.reason === 'quote' ||
-    notif.reason === 'follow'
+    notif.reason === 'follow' ||
+    notif.reason === 'starterpack-joined'
   ) {
     return notif.reason as NotificationType
   }
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 2bb5f4d28..112a62c83 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -26,7 +26,15 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
         limit: PAGE_SIZE,
         cursor: pageParam,
       })
-      return res.data
+
+      // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably
+      // just filter this out on the backend instead of in the client.
+      return {
+        ...res.data,
+        lists: res.data.lists.filter(
+          l => l.purpose !== 'app.bsky.graph.defs#referencelist',
+        ),
+      }
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
diff --git a/src/state/queries/shorten-link.ts b/src/state/queries/shorten-link.ts
new file mode 100644
index 000000000..76c63c356
--- /dev/null
+++ b/src/state/queries/shorten-link.ts
@@ -0,0 +1,23 @@
+import {logger} from '#/logger'
+
+export function useShortenLink() {
+  return async (inputUrl: string): Promise<{url: string}> => {
+    const url = new URL(inputUrl)
+    const res = await fetch('https://go.bsky.app/link', {
+      method: 'POST',
+      body: JSON.stringify({
+        path: url.pathname,
+      }),
+      headers: {
+        'Content-Type': 'application/json',
+      },
+    })
+
+    if (!res.ok) {
+      logger.error('Failed to shorten link', {safeMessage: res.status})
+      return {url: inputUrl}
+    }
+
+    return res.json()
+  }
+}
diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts
new file mode 100644
index 000000000..241bc6419
--- /dev/null
+++ b/src/state/queries/starter-packs.ts
@@ -0,0 +1,317 @@
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  AppBskyGraphGetStarterPack,
+  AppBskyGraphStarterpack,
+  AtUri,
+  BskyAgent,
+} from '@atproto/api'
+import {StarterPackView} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
+import {
+  QueryClient,
+  useMutation,
+  useQuery,
+  useQueryClient,
+} from '@tanstack/react-query'
+
+import {until} from 'lib/async/until'
+import {createStarterPackList} from 'lib/generate-starterpack'
+import {
+  createStarterPackUri,
+  httpStarterPackUriToAtUri,
+  parseStarterPackUri,
+} from 'lib/strings/starter-pack'
+import {invalidateActorStarterPacksQuery} from 'state/queries/actor-starter-packs'
+import {invalidateListMembersQuery} from 'state/queries/list-members'
+import {useAgent} from 'state/session'
+
+const RQKEY_ROOT = 'starter-pack'
+const RQKEY = (did?: string, rkey?: string) => {
+  if (did?.startsWith('https://') || did?.startsWith('at://')) {
+    const parsed = parseStarterPackUri(did)
+    return [RQKEY_ROOT, parsed?.name, parsed?.rkey]
+  } else {
+    return [RQKEY_ROOT, did, rkey]
+  }
+}
+
+export function useStarterPackQuery({
+  uri,
+  did,
+  rkey,
+}: {
+  uri?: string
+  did?: string
+  rkey?: string
+}) {
+  const agent = useAgent()
+
+  return useQuery<StarterPackView>({
+    queryKey: RQKEY(did, rkey),
+    queryFn: async () => {
+      if (!uri) {
+        uri = `at://${did}/app.bsky.graph.starterpack/${rkey}`
+      } else if (uri && !uri.startsWith('at://')) {
+        uri = httpStarterPackUriToAtUri(uri) as string
+      }
+
+      const res = await agent.app.bsky.graph.getStarterPack({
+        starterPack: uri,
+      })
+      return res.data.starterPack
+    },
+    enabled: Boolean(uri) || Boolean(did && rkey),
+  })
+}
+
+export async function invalidateStarterPack({
+  queryClient,
+  did,
+  rkey,
+}: {
+  queryClient: QueryClient
+  did: string
+  rkey: string
+}) {
+  await queryClient.invalidateQueries({queryKey: RQKEY(did, rkey)})
+}
+
+interface UseCreateStarterPackMutationParams {
+  name: string
+  description?: string
+  descriptionFacets: []
+  profiles: AppBskyActorDefs.ProfileViewBasic[]
+  feeds?: AppBskyFeedDefs.GeneratorView[]
+}
+
+export function useCreateStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: (data: {uri: string; cid: string}) => void
+  onError: (e: Error) => void
+}) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    UseCreateStarterPackMutationParams
+  >({
+    mutationFn: async params => {
+      let listRes
+      listRes = await createStarterPackList({...params, agent})
+      return await agent.app.bsky.graph.starterpack.create(
+        {
+          repo: agent.session?.did,
+        },
+        {
+          ...params,
+          list: listRes?.uri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+    },
+    onSuccess: async data => {
+      await whenAppViewReady(agent, data.uri, v => {
+        return typeof v?.data.starterPack.uri === 'string'
+      })
+      await invalidateActorStarterPacksQuery({
+        queryClient,
+        did: agent.session!.did,
+      })
+      onSuccess(data)
+    },
+    onError: async error => {
+      onError(error)
+    },
+  })
+}
+
+export function useEditStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: () => void
+  onError: (error: Error) => void
+}) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation<
+    void,
+    Error,
+    UseCreateStarterPackMutationParams & {
+      currentStarterPack: AppBskyGraphDefs.StarterPackView
+      currentListItems: AppBskyGraphDefs.ListItemView[]
+    }
+  >({
+    mutationFn: async params => {
+      const {
+        name,
+        description,
+        descriptionFacets,
+        feeds,
+        profiles,
+        currentStarterPack,
+        currentListItems,
+      } = params
+
+      if (!AppBskyGraphStarterpack.isRecord(currentStarterPack.record)) {
+        throw new Error('Invalid starter pack')
+      }
+
+      const removedItems = currentListItems.filter(
+        i =>
+          i.subject.did !== agent.session?.did &&
+          !profiles.find(p => p.did === i.subject.did && p.did),
+      )
+
+      if (removedItems.length !== 0) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: agent.session!.did,
+          writes: removedItems.map(i => ({
+            $type: 'com.atproto.repo.applyWrites#delete',
+            collection: 'app.bsky.graph.listitem',
+            rkey: new AtUri(i.uri).rkey,
+          })),
+        })
+      }
+
+      const addedProfiles = profiles.filter(
+        p => !currentListItems.find(i => i.subject.did === p.did),
+      )
+
+      if (addedProfiles.length > 0) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: agent.session!.did,
+          writes: addedProfiles.map(p => ({
+            $type: 'com.atproto.repo.applyWrites#create',
+            collection: 'app.bsky.graph.listitem',
+            value: {
+              $type: 'app.bsky.graph.listitem',
+              subject: p.did,
+              list: currentStarterPack.list?.uri,
+              createdAt: new Date().toISOString(),
+            },
+          })),
+        })
+      }
+
+      const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey
+      await agent.com.atproto.repo.putRecord({
+        repo: agent.session!.did,
+        collection: 'app.bsky.graph.starterpack',
+        rkey,
+        record: {
+          name,
+          description,
+          descriptionFacets,
+          list: currentStarterPack.list?.uri,
+          feeds,
+          createdAt: currentStarterPack.record.createdAt,
+          updatedAt: new Date().toISOString(),
+        },
+      })
+    },
+    onSuccess: async (_, {currentStarterPack}) => {
+      const parsed = parseStarterPackUri(currentStarterPack.uri)
+      await whenAppViewReady(agent, currentStarterPack.uri, v => {
+        return currentStarterPack.cid !== v?.data.starterPack.cid
+      })
+      await invalidateActorStarterPacksQuery({
+        queryClient,
+        did: agent.session!.did,
+      })
+      if (currentStarterPack.list) {
+        await invalidateListMembersQuery({
+          queryClient,
+          uri: currentStarterPack.list.uri,
+        })
+      }
+      await invalidateStarterPack({
+        queryClient,
+        did: agent.session!.did,
+        rkey: parsed!.rkey,
+      })
+      onSuccess()
+    },
+    onError: error => {
+      onError(error)
+    },
+  })
+}
+
+export function useDeleteStarterPackMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess: () => void
+  onError: (error: Error) => void
+}) {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async ({listUri, rkey}: {listUri?: string; rkey: string}) => {
+      if (!agent.session) {
+        throw new Error(`Requires logged in user`)
+      }
+
+      if (listUri) {
+        await agent.app.bsky.graph.list.delete({
+          repo: agent.session.did,
+          rkey: new AtUri(listUri).rkey,
+        })
+      }
+      await agent.app.bsky.graph.starterpack.delete({
+        repo: agent.session.did,
+        rkey,
+      })
+    },
+    onSuccess: async (_, {listUri, rkey}) => {
+      const uri = createStarterPackUri({
+        did: agent.session!.did,
+        rkey,
+      })
+
+      if (uri) {
+        await whenAppViewReady(agent, uri, v => {
+          return Boolean(v?.data?.starterPack) === false
+        })
+      }
+
+      if (listUri) {
+        await invalidateListMembersQuery({queryClient, uri: listUri})
+      }
+      await invalidateActorStarterPacksQuery({
+        queryClient,
+        did: agent.session!.did,
+      })
+      await invalidateStarterPack({
+        queryClient,
+        did: agent.session!.did,
+        rkey,
+      })
+      onSuccess()
+    },
+    onError: error => {
+      onError(error)
+    },
+  })
+}
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  uri: string,
+  fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () => agent.app.bsky.graph.getStarterPack({starterPack: uri}),
+  )
+}
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 5a58937fa..4bcb4c11c 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -127,18 +127,6 @@ export async function createAgentAndCreateAccount(
   const account = agentToSessionAccountOrThrow(agent)
   const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
   const moderation = configureModerationForAccount(agent, account)
-  if (!account.signupQueued) {
-    /*dont await*/ agent.upsertProfile(_existing => {
-      return {
-        displayName: '',
-        // HACKFIX
-        // creating a bunch of identical profile objects is breaking the relay
-        // tossing this unspecced field onto it to reduce the size of the problem
-        // -prf
-        createdAt: new Date().toISOString(),
-      }
-    })
-  }
 
   // Not awaited so that we can still get into onboarding.
   // This is OK because we won't let you toggle adult stuff until you set the date.
diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx
index 8fe2a9c01..dc78d03d5 100644
--- a/src/state/shell/logged-out.tsx
+++ b/src/state/shell/logged-out.tsx
@@ -1,5 +1,9 @@
 import React from 'react'
 
+import {isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {useActiveStarterPack} from 'state/shell/starter-pack'
+
 type State = {
   showLoggedOut: boolean
   /**
@@ -22,7 +26,7 @@ type Controls = {
     /**
      * The did of the account to populate the login form with.
      */
-    requestedAccount?: string | 'none' | 'new'
+    requestedAccount?: string | 'none' | 'new' | 'starterpack'
   }) => void
   /**
    * Clears the requested account so that next time the logged out view is
@@ -43,9 +47,16 @@ const ControlsContext = React.createContext<Controls>({
 })
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
+  const activeStarterPack = useActiveStarterPack()
+  const {hasSession} = useSession()
+  const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession
   const [state, setState] = React.useState<State>({
-    showLoggedOut: false,
-    requestedAccountSwitchTo: undefined,
+    showLoggedOut: shouldShowStarterPack,
+    requestedAccountSwitchTo: shouldShowStarterPack
+      ? isWeb
+        ? 'starterpack'
+        : 'new'
+      : undefined,
   })
 
   const controls = React.useMemo<Controls>(
diff --git a/src/state/shell/starter-pack.tsx b/src/state/shell/starter-pack.tsx
new file mode 100644
index 000000000..f564712f0
--- /dev/null
+++ b/src/state/shell/starter-pack.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+
+type StateContext =
+  | {
+      uri: string
+      isClip?: boolean
+    }
+  | undefined
+type SetContext = (v: StateContext) => void
+
+const stateContext = React.createContext<StateContext>(undefined)
+const setContext = React.createContext<SetContext>((_: StateContext) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [state, setState] = React.useState<StateContext>()
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setState}>{children}</setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export const useActiveStarterPack = () => React.useContext(stateContext)
+export const useSetActiveStarterPack = () => React.useContext(setContext)
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index c8c81dd77..29127ec45 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {logEvent} from '#/lib/statsig/statsig'
 import {s} from '#/lib/styles'
 import {isIOS, isNative} from '#/platform/detection'
@@ -22,13 +21,16 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {Text} from '#/view/com/util/text/Text'
 import {Login} from '#/screens/Login'
 import {Signup} from '#/screens/Signup'
+import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
 import {SplashScreen} from './SplashScreen'
 
 enum ScreenState {
   S_LoginOrCreateAccount,
   S_Login,
   S_CreateAccount,
+  S_StarterPack,
 }
+export {ScreenState as LoggedOutScreenState}
 
 export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
   const {hasSession} = useSession()
@@ -37,18 +39,21 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const {screen} = useAnalytics()
   const {requestedAccountSwitchTo} = useLoggedOutView()
-  const [screenState, setScreenState] = React.useState<ScreenState>(
-    requestedAccountSwitchTo
-      ? requestedAccountSwitchTo === 'new'
-        ? ScreenState.S_CreateAccount
-        : ScreenState.S_Login
-      : ScreenState.S_LoginOrCreateAccount,
-  )
-  const {isMobile} = useWebMediaQueries()
+  const [screenState, setScreenState] = React.useState<ScreenState>(() => {
+    if (requestedAccountSwitchTo === 'new') {
+      return ScreenState.S_CreateAccount
+    } else if (requestedAccountSwitchTo === 'starterpack') {
+      return ScreenState.S_StarterPack
+    } else if (requestedAccountSwitchTo != null) {
+      return ScreenState.S_Login
+    } else {
+      return ScreenState.S_LoginOrCreateAccount
+    }
+  })
   const {clearRequestedAccount} = useLoggedOutViewControls()
   const navigation = useNavigation<NavigationProp>()
-  const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
 
+  const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
   React.useEffect(() => {
     screen('Login')
     setMinimalShellMode(true)
@@ -66,18 +71,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
   }, [navigation])
 
   return (
-    <View
-      testID="noSessionView"
-      style={[
-        s.hContentRegion,
-        pal.view,
-        {
-          // only needed if dismiss button is present
-          paddingTop: onDismiss && isMobile ? 40 : 0,
-        },
-      ]}>
+    <View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
       <ErrorBoundary>
-        {onDismiss ? (
+        {onDismiss && screenState === ScreenState.S_LoginOrCreateAccount ? (
           <Pressable
             accessibilityHint={_(msg`Go back`)}
             accessibilityLabel={_(msg`Go back`)}
@@ -132,7 +128,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
           </Pressable>
         ) : null}
 
-        {screenState === ScreenState.S_LoginOrCreateAccount ? (
+        {screenState === ScreenState.S_StarterPack ? (
+          <LandingScreen setScreenState={setScreenState} />
+        ) : screenState === ScreenState.S_LoginOrCreateAccount ? (
           <SplashScreen
             onPressSignin={() => {
               setScreenState(ScreenState.S_Login)
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index a61789434..d216849c5 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -329,6 +329,9 @@ const styles = StyleSheet.create({
     flex: 1,
     gap: 14,
   },
+  border: {
+    borderTopWidth: hairlineWidth,
+  },
   headerContainer: {
     flexDirection: 'row',
   },
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 9cd7a2917..2f8d65a1d 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -52,7 +52,16 @@ import {TimeElapsed} from '../util/TimeElapsed'
 import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
 
 import hairlineWidth = StyleSheet.hairlineWidth
+import {useNavigation} from '@react-navigation/native'
+
 import {parseTenorGif} from '#/lib/strings/embed-player'
+import {logger} from '#/logger'
+import {NavigationProp} from 'lib/routes/types'
+import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
+import {useAgent} from 'state/session'
+import {Button, ButtonText} from '#/components/Button'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
 
 const MAX_AUTHORS = 5
 
@@ -89,7 +98,10 @@ let FeedItem = ({
     } else if (item.type === 'reply') {
       const urip = new AtUri(item.notification.uri)
       return `/profile/${urip.host}/post/${urip.rkey}`
-    } else if (item.type === 'feedgen-like') {
+    } else if (
+      item.type === 'feedgen-like' ||
+      item.type === 'starterpack-joined'
+    ) {
       if (item.subjectUri) {
         const urip = new AtUri(item.subjectUri)
         return `/profile/${urip.host}/feed/${urip.rkey}`
@@ -176,6 +188,13 @@ let FeedItem = ({
     icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} />
   } else if (item.type === 'feedgen-like') {
     action = _(msg`liked your custom feed`)
+  } else if (item.type === 'starterpack-joined') {
+    icon = (
+      <View style={{height: 30, width: 30}}>
+        <StarterPack width={30} gradient="sky" />
+      </View>
+    )
+    action = _(msg`signed up with your starter pack`)
   } else {
     return null
   }
@@ -289,6 +308,20 @@ let FeedItem = ({
             showLikes
           />
         ) : null}
+        {item.type === 'starterpack-joined' ? (
+          <View>
+            <View
+              style={[
+                a.border,
+                a.p_sm,
+                a.rounded_sm,
+                a.mt_sm,
+                t.atoms.border_contrast_low,
+              ]}>
+              <StarterPackCard starterPack={item.subject} />
+            </View>
+          </View>
+        ) : null}
       </View>
     </Link>
   )
@@ -319,14 +352,63 @@ function ExpandListPressable({
   }
 }
 
+function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
+  const {_} = useLingui()
+  const agent = useAgent()
+  const navigation = useNavigation<NavigationProp>()
+  const [isLoading, setIsLoading] = React.useState(false)
+
+  if (
+    profile.associated?.chat?.allowIncoming === 'none' ||
+    (profile.associated?.chat?.allowIncoming === 'following' &&
+      !profile.viewer?.followedBy)
+  ) {
+    return null
+  }
+
+  return (
+    <Button
+      label={_(msg`Say hello!`)}
+      variant="ghost"
+      color="primary"
+      size="xsmall"
+      style={[a.self_center, {marginLeft: 'auto'}]}
+      disabled={isLoading}
+      onPress={async () => {
+        try {
+          setIsLoading(true)
+          const res = await agent.api.chat.bsky.convo.getConvoForMembers(
+            {
+              members: [profile.did, agent.session!.did!],
+            },
+            {headers: DM_SERVICE_HEADERS},
+          )
+          navigation.navigate('MessagesConversation', {
+            conversation: res.data.convo.id,
+          })
+        } catch (e) {
+          logger.error('Failed to get conversation', {safeMessage: e})
+        } finally {
+          setIsLoading(false)
+        }
+      }}>
+      <ButtonText>
+        <Trans>Say hello!</Trans>
+      </ButtonText>
+    </Button>
+  )
+}
+
 function CondensedAuthorsList({
   visible,
   authors,
   onToggleAuthorsExpanded,
+  showDmButton = true,
 }: {
   visible: boolean
   authors: Author[]
   onToggleAuthorsExpanded: () => void
+  showDmButton?: boolean
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -355,7 +437,7 @@ function CondensedAuthorsList({
   }
   if (authors.length === 1) {
     return (
-      <View style={styles.avis}>
+      <View style={[styles.avis]}>
         <PreviewableUserAvatar
           size={35}
           profile={authors[0].profile}
@@ -363,6 +445,7 @@ function CondensedAuthorsList({
           type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'}
           accessible={false}
         />
+        {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null}
       </View>
     )
   }
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 7b090ffeb..8e63da85b 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,12 +1,13 @@
 import React from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
 import {AppBskyActorDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Shadow} from '#/state/cache/types'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {Button, ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {useProfileFollowMutationQueue} from '#/state/queries/profile'
-import {Shadow} from '#/state/cache/types'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
 
 export function FollowButton({
   unfollowedType = 'inverted',
@@ -19,7 +20,7 @@ export function FollowButton({
   followedType?: ButtonType
   profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
   labelStyle?: StyleProp<TextStyle>
-  logContext: 'ProfileCard'
+  logContext: 'ProfileCard' | 'StarterPackProfilesList'
 }) {
   const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
     profile,
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index a3cd5ca1b..d7ed0dd6a 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -251,12 +251,14 @@ export function ProfileCardWithFollowBtn({
   noBorder,
   followers,
   onPress,
+  logContext = 'ProfileCard',
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
   noBg?: boolean
   noBorder?: boolean
   followers?: AppBskyActorDefs.ProfileView[] | undefined
   onPress?: () => void
+  logContext?: 'ProfileCard' | 'StarterPackProfilesList'
 }) {
   const {currentAccount} = useSession()
   const isMe = profile.did === currentAccount?.did
@@ -271,7 +273,7 @@ export function ProfileCardWithFollowBtn({
         isMe
           ? undefined
           : profileShadow => (
-              <FollowButton profile={profileShadow} logContext="ProfileCard" />
+              <FollowButton profile={profileShadow} logContext={logContext} />
             )
       }
       onPress={onPress}
@@ -314,6 +316,7 @@ const styles = StyleSheet.create({
     paddingRight: 10,
   },
   details: {
+    justifyContent: 'center',
     paddingLeft: 54,
     paddingRight: 10,
     paddingBottom: 10,
@@ -339,7 +342,6 @@ const styles = StyleSheet.create({
 
   followedBy: {
     flexDirection: 'row',
-    alignItems: 'center',
     paddingLeft: 54,
     paddingRight: 20,
     marginBottom: 10,
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
index edc6b75f9..ac5febcda 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -21,7 +21,9 @@ import {Text} from '../util/text/Text'
 import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
 import {CenteredView} from '../util/Views'
 import hairlineWidth = StyleSheet.hairlineWidth
+
 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
+import {StarterPack} from '#/components/icons/StarterPack'
 
 export function ProfileSubpageHeader({
   isLoading,
@@ -44,7 +46,7 @@ export function ProfileSubpageHeader({
         handle: string
       }
     | undefined
-  avatarType: UserAvatarType
+  avatarType: UserAvatarType | 'starter-pack'
 }>) {
   const setDrawerOpen = useSetDrawerOpen()
   const navigation = useNavigation<NavigationProp>()
@@ -127,7 +129,11 @@ export function ProfileSubpageHeader({
           accessibilityLabel={_(msg`View the avatar`)}
           accessibilityHint=""
           style={{width: 58}}>
-          <UserAvatar type={avatarType} size={58} avatar={avatar} />
+          {avatarType === 'starter-pack' ? (
+            <StarterPack width={58} gradient="sky" />
+          ) : (
+            <UserAvatar type={avatarType} size={58} avatar={avatar} />
+          )}
         </Pressable>
         <View style={{flex: 1}}>
           {isLoading ? (
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index e49f2fbb2..dfadf9bbe 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -30,7 +30,7 @@ import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
 import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
 import {HomeHeader} from '../com/home/HomeHeader'
 
-type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
+type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
 export function HomeScreen(props: Props) {
   const {data: preferences} = usePreferencesQuery()
   const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 734230c6c..946f6ac54 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,7 +1,8 @@
-import React, {useMemo} from 'react'
+import React, {useCallback, useMemo} from 'react'
 import {StyleSheet} from 'react-native'
 import {
   AppBskyActorDefs,
+  AppBskyGraphGetActorStarterPacks,
   moderateProfile,
   ModerationOpts,
   RichText as RichTextAPI,
@@ -9,7 +10,11 @@ import {
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
-import {useQueryClient} from '@tanstack/react-query'
+import {
+  InfiniteData,
+  UseInfiniteQueryResult,
+  useQueryClient,
+} from '@tanstack/react-query'
 
 import {cleanError} from '#/lib/strings/errors'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -22,18 +27,23 @@ import {useAgent, useSession} from '#/state/session'
 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
 import {useComposerControls} from '#/state/shell/composer'
 import {useAnalytics} from 'lib/analytics/analytics'
+import {IS_DEV, IS_TESTFLIGHT} from 'lib/app-info'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {ComposeIcon2} from 'lib/icons'
 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {useGate} from 'lib/statsig/statsig'
 import {combinedDisplayName} from 'lib/strings/display-names'
 import {isInvalidHandle} from 'lib/strings/handles'
 import {colors, s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
 import {listenSoftReset} from 'state/events'
+import {useActorStarterPacksQuery} from 'state/queries/actor-starter-packs'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
 import {ScreenHider} from '#/components/moderation/ScreenHider'
+import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
 import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
 import {ProfileLists} from '../com/lists/ProfileLists'
@@ -69,6 +79,7 @@ export function ProfileScreen({route}: Props) {
   } = useProfileQuery({
     did: resolvedDid,
   })
+  const starterPacksQuery = useActorStarterPacksQuery({did: resolvedDid})
 
   const onPressTryAgain = React.useCallback(() => {
     if (resolveError) {
@@ -86,7 +97,7 @@ export function ProfileScreen({route}: Props) {
   }, [queryClient, profile?.viewer?.blockedBy, resolvedDid])
 
   // Most pushes will happen here, since we will have only placeholder data
-  if (isLoadingDid || isLoadingProfile) {
+  if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) {
     return (
       <CenteredView>
         <ProfileHeaderLoading />
@@ -108,6 +119,7 @@ export function ProfileScreen({route}: Props) {
     return (
       <ProfileScreenLoaded
         profile={profile}
+        starterPacksQuery={starterPacksQuery}
         moderationOpts={moderationOpts}
         isPlaceholderProfile={isPlaceholderProfile}
         hideBackButton={!!route.params.hideBackButton}
@@ -131,11 +143,16 @@ function ProfileScreenLoaded({
   isPlaceholderProfile,
   moderationOpts,
   hideBackButton,
+  starterPacksQuery,
 }: {
   profile: AppBskyActorDefs.ProfileViewDetailed
   moderationOpts: ModerationOpts
   hideBackButton: boolean
   isPlaceholderProfile: boolean
+  starterPacksQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
+    Error
+  >
 }) {
   const profile = useProfileShadow(profileUnshadowed)
   const {hasSession, currentAccount} = useSession()
@@ -153,6 +170,9 @@ function ProfileScreenLoaded({
   const [currentPage, setCurrentPage] = React.useState(0)
   const {_} = useLingui()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+  const gate = useGate()
+  const starterPacksEnabled =
+    IS_DEV || IS_TESTFLIGHT || (!isWeb && gate('starter_packs_enabled'))
 
   const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null)
 
@@ -162,6 +182,7 @@ function ProfileScreenLoaded({
   const likesSectionRef = React.useRef<SectionRef>(null)
   const feedsSectionRef = React.useRef<SectionRef>(null)
   const listsSectionRef = React.useRef<SectionRef>(null)
+  const starterPacksSectionRef = React.useRef<SectionRef>(null)
   const labelsSectionRef = React.useRef<SectionRef>(null)
 
   useSetTitle(combinedDisplayName(profile))
@@ -183,31 +204,23 @@ function ProfileScreenLoaded({
   const showMediaTab = !hasLabeler
   const showLikesTab = isMe
   const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0
+  const showStarterPacksTab =
+    starterPacksEnabled &&
+    (isMe || !!starterPacksQuery.data?.pages?.[0].starterPacks.length)
   const showListsTab =
     hasSession && (isMe || (profile.associated?.lists || 0) > 0)
 
-  const sectionTitles = useMemo<string[]>(() => {
-    return [
-      showFiltersTab ? _(msg`Labels`) : undefined,
-      showListsTab && hasLabeler ? _(msg`Lists`) : undefined,
-      showPostsTab ? _(msg`Posts`) : undefined,
-      showRepliesTab ? _(msg`Replies`) : undefined,
-      showMediaTab ? _(msg`Media`) : undefined,
-      showLikesTab ? _(msg`Likes`) : undefined,
-      showFeedsTab ? _(msg`Feeds`) : undefined,
-      showListsTab && !hasLabeler ? _(msg`Lists`) : undefined,
-    ].filter(Boolean) as string[]
-  }, [
-    showPostsTab,
-    showRepliesTab,
-    showMediaTab,
-    showLikesTab,
-    showFeedsTab,
-    showListsTab,
-    showFiltersTab,
-    hasLabeler,
-    _,
-  ])
+  const sectionTitles = [
+    showFiltersTab ? _(msg`Labels`) : undefined,
+    showListsTab && hasLabeler ? _(msg`Lists`) : undefined,
+    showPostsTab ? _(msg`Posts`) : undefined,
+    showRepliesTab ? _(msg`Replies`) : undefined,
+    showMediaTab ? _(msg`Media`) : undefined,
+    showLikesTab ? _(msg`Likes`) : undefined,
+    showFeedsTab ? _(msg`Feeds`) : undefined,
+    showStarterPacksTab ? _(msg`Starter Packs`) : undefined,
+    showListsTab && !hasLabeler ? _(msg`Lists`) : undefined,
+  ].filter(Boolean) as string[]
 
   let nextIndex = 0
   let filtersIndex: number | null = null
@@ -216,6 +229,7 @@ function ProfileScreenLoaded({
   let mediaIndex: number | null = null
   let likesIndex: number | null = null
   let feedsIndex: number | null = null
+  let starterPacksIndex: number | null = null
   let listsIndex: number | null = null
   if (showFiltersTab) {
     filtersIndex = nextIndex++
@@ -235,11 +249,14 @@ function ProfileScreenLoaded({
   if (showFeedsTab) {
     feedsIndex = nextIndex++
   }
+  if (showStarterPacksTab) {
+    starterPacksIndex = nextIndex++
+  }
   if (showListsTab) {
     listsIndex = nextIndex++
   }
 
-  const scrollSectionToTop = React.useCallback(
+  const scrollSectionToTop = useCallback(
     (index: number) => {
       if (index === filtersIndex) {
         labelsSectionRef.current?.scrollToTop()
@@ -253,6 +270,8 @@ function ProfileScreenLoaded({
         likesSectionRef.current?.scrollToTop()
       } else if (index === feedsIndex) {
         feedsSectionRef.current?.scrollToTop()
+      } else if (index === starterPacksIndex) {
+        starterPacksSectionRef.current?.scrollToTop()
       } else if (index === listsIndex) {
         listsSectionRef.current?.scrollToTop()
       }
@@ -265,6 +284,7 @@ function ProfileScreenLoaded({
       likesIndex,
       feedsIndex,
       listsIndex,
+      starterPacksIndex,
     ],
   )
 
@@ -290,7 +310,7 @@ function ProfileScreenLoaded({
   // events
   // =
 
-  const onPressCompose = React.useCallback(() => {
+  const onPressCompose = () => {
     track('ProfileScreen:PressCompose')
     const mention =
       profile.handle === currentAccount?.handle ||
@@ -298,23 +318,20 @@ function ProfileScreenLoaded({
         ? undefined
         : profile.handle
     openComposer({mention})
-  }, [openComposer, currentAccount, track, profile])
+  }
 
-  const onPageSelected = React.useCallback((i: number) => {
+  const onPageSelected = (i: number) => {
     setCurrentPage(i)
-  }, [])
+  }
 
-  const onCurrentPageSelected = React.useCallback(
-    (index: number) => {
-      scrollSectionToTop(index)
-    },
-    [scrollSectionToTop],
-  )
+  const onCurrentPageSelected = (index: number) => {
+    scrollSectionToTop(index)
+  }
 
   // rendering
   // =
 
-  const renderHeader = React.useCallback(() => {
+  const renderHeader = () => {
     return (
       <ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
         <ProfileHeader
@@ -327,16 +344,7 @@ function ProfileScreenLoaded({
         />
       </ExpoScrollForwarderView>
     )
-  }, [
-    scrollViewTag,
-    profile,
-    labelerInfo,
-    hasDescription,
-    descriptionRT,
-    moderationOpts,
-    hideBackButton,
-    showPlaceholder,
-  ])
+  }
 
   return (
     <ScreenHider
@@ -442,6 +450,19 @@ function ProfileScreenLoaded({
               />
             )
           : null}
+        {showStarterPacksTab
+          ? ({headerHeight, isFocused, scrollElRef}) => (
+              <ProfileStarterPacks
+                ref={starterPacksSectionRef}
+                isMe={isMe}
+                starterPacksQuery={starterPacksQuery}
+                scrollElRef={scrollElRef as ListRef}
+                headerOffset={headerHeight}
+                enabled={isFocused}
+                setScrollViewTag={setScrollViewTag}
+              />
+            )
+          : null}
         {showListsTab && !profile.associated?.labeler
           ? ({headerHeight, isFocused, scrollElRef}) => (
               <ProfileLists
diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx
index bff1fdc9b..9de126d6b 100644
--- a/src/view/screens/Storybook/Icons.tsx
+++ b/src/view/screens/Storybook/Icons.tsx
@@ -45,6 +45,14 @@ export function Icons() {
         <Loader size="lg" fill={t.atoms.text.color} />
         <Loader size="xl" fill={t.atoms.text.color} />
       </View>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <Globe size="xs" gradient="sky" />
+        <Globe size="sm" gradient="sky" />
+        <Globe size="md" gradient="sky" />
+        <Globe size="lg" gradient="sky" />
+        <Globe size="xl" gradient="sky" />
+      </View>
     </View>
   )
 }
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 9b2b4922a..ca8073f57 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -100,12 +100,18 @@ function ProfileCard() {
   )
 }
 
+const HIDDEN_BACK_BNT_ROUTES = ['StarterPackWizard', 'StarterPackEdit']
+
 function BackBtn() {
   const {isTablet} = useWebMediaQueries()
   const pal = usePalette('default')
   const navigation = useNavigation<NavigationProp>()
   const {_} = useLingui()
-  const shouldShow = useNavigationState(state => !isStateAtTabRoot(state))
+  const shouldShow = useNavigationState(
+    state =>
+      !isStateAtTabRoot(state) &&
+      !HIDDEN_BACK_BNT_ROUTES.includes(getCurrentRoute(state).name),
+  )
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
diff --git a/yarn.lock b/yarn.lock
index a0fd8749a..b93f93304 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,10 +34,10 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@^0.12.20":
-  version "0.12.20"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3"
-  integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg==
+"@atproto/api@0.12.22-next.0":
+  version "0.12.22-next.0"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22-next.0.tgz#7996f651468e3fb151663df28a9938d92bd0660a"
+  integrity sha512-LKmOrQvBvIlheLv+ns85bCrP23DbYfk8UQkFikLBEqPKQW10F9ZwsJ6oBUfrWv6pEI4Mn0mrn8cFQkvdZ2i2sg==
   dependencies:
     "@atproto/common-web" "^0.3.0"
     "@atproto/lexicon" "^0.4.0"
@@ -3382,46 +3382,46 @@
     node-forge "^1.2.1"
     nullthrows "^1.1.1"
 
-"@expo/config-plugins@7.8.0", "@expo/config-plugins@~7.8.0":
-  version "7.8.0"
-  resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.0.tgz#70fd87237faf6a5c3bf47277b67f7b22f9b12c05"
-  integrity sha512-bCJB/uTP2D520l36M0zMVzxzu25ISdEniE42SjgtFnbIzKae2s9Jd91CT/90qEoF2EXeAVlXwn2nCIiY8FTU3A==
+"@expo/config-plugins@8.0.4", "@expo/config-plugins@~8.0.0", "@expo/config-plugins@~8.0.0-beta.0":
+  version "8.0.4"
+  resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.4.tgz#1e781cd971fab27409ed2f8d621db6d29cce3036"
+  integrity sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw==
   dependencies:
-    "@expo/config-types" "^50.0.0-alpha.1"
-    "@expo/fingerprint" "^0.6.0"
+    "@expo/config-types" "^51.0.0-unreleased"
     "@expo/json-file" "~8.3.0"
     "@expo/plist" "^0.1.0"
     "@expo/sdk-runtime-versions" "^1.0.0"
-    "@react-native/normalize-color" "^2.0.0"
     chalk "^4.1.2"
     debug "^4.3.1"
     find-up "~5.0.0"
     getenv "^1.0.0"
     glob "7.1.6"
     resolve-from "^5.0.0"
-    semver "^7.5.3"
+    semver "^7.5.4"
     slash "^3.0.0"
+    slugify "^1.6.6"
     xcode "^3.0.1"
     xml2js "0.6.0"
 
-"@expo/config-plugins@8.0.4", "@expo/config-plugins@~8.0.0", "@expo/config-plugins@~8.0.0-beta.0":
-  version "8.0.4"
-  resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.4.tgz#1e781cd971fab27409ed2f8d621db6d29cce3036"
-  integrity sha512-Hi+xuyNWE2LT4LVbGttHJgl9brnsdWAhEB42gWKb5+8ae86Nr/KwUBQJsJppirBYTeLjj5ZlY0glYnAkDa2jqw==
+"@expo/config-plugins@~7.8.0":
+  version "7.8.0"
+  resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.0.tgz#70fd87237faf6a5c3bf47277b67f7b22f9b12c05"
+  integrity sha512-bCJB/uTP2D520l36M0zMVzxzu25ISdEniE42SjgtFnbIzKae2s9Jd91CT/90qEoF2EXeAVlXwn2nCIiY8FTU3A==
   dependencies:
-    "@expo/config-types" "^51.0.0-unreleased"
+    "@expo/config-types" "^50.0.0-alpha.1"
+    "@expo/fingerprint" "^0.6.0"
     "@expo/json-file" "~8.3.0"
     "@expo/plist" "^0.1.0"
     "@expo/sdk-runtime-versions" "^1.0.0"
+    "@react-native/normalize-color" "^2.0.0"
     chalk "^4.1.2"
     debug "^4.3.1"
     find-up "~5.0.0"
     getenv "^1.0.0"
     glob "7.1.6"
     resolve-from "^5.0.0"
-    semver "^7.5.4"
+    semver "^7.5.3"
     slash "^3.0.0"
-    slugify "^1.6.6"
     xcode "^3.0.1"
     xml2js "0.6.0"
 
@@ -10969,6 +10969,11 @@ diff@^4.0.1:
   resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
   integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
 
+dijkstrajs@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
+  integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
+
 dir-glob@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -11234,6 +11239,11 @@ emojis-list@^3.0.0:
   resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
   integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
 
+encode-utf8@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
+  integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
+
 encodeurl@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
@@ -17609,6 +17619,11 @@ pngjs@^3.3.0:
   resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
   integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
 
+pngjs@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
+  integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
+
 pofile@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2"
@@ -18565,6 +18580,16 @@ qrcode-terminal@0.11.0:
   resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e"
   integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==
 
+qrcode@^1.5.1:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170"
+  integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==
+  dependencies:
+    dijkstrajs "^1.0.1"
+    encode-utf8 "^1.0.3"
+    pngjs "^5.0.0"
+    yargs "^15.3.1"
+
 qs@6.11.0:
   version "6.11.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
@@ -18860,6 +18885,13 @@ react-native-progress@bluesky-social/react-native-progress:
   dependencies:
     prop-types "^15.7.2"
 
+react-native-qrcode-styled@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/react-native-qrcode-styled/-/react-native-qrcode-styled-0.3.1.tgz#be6a0fab173511b0d3d8d71588771c2230982dbf"
+  integrity sha512-Q4EqbIFV0rpCYcdmWY51+H8Vrc0fvP01hPkiSqPEmjjxhm6mqyAuTMdNHNEddLXZzCVQCJujvj6IrHjdAhKjnA==
+  dependencies:
+    qrcode "^1.5.1"
+
 react-native-reanimated@^3.11.0:
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.11.0.tgz#d4265d4e0232623f5958ed60e1686ca884fc3452"
@@ -22395,7 +22427,7 @@ yargs-parser@^21.1.1:
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
   integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
 
-yargs@^15.1.0:
+yargs@^15.1.0, yargs@^15.3.1:
   version "15.4.1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
   integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==