naystack
v1.9.9
Published
A stack built with Next + GraphQL + S3 + Auth
Downloads
2,213
Maintainers
Readme
Naystack
A minimal, powerful stack for Next.js app development. Provides end-to-end Auth + GraphQL + File Upload + Other utilities. With bring-your-own database.
Installation
pnpm add naystack1. Authentication
Naystack provides a seamless email-based authentication system with optional Google OAuth.
Server Setup
Define your auth routes in app/api/(auth)/email/route.ts. The library reads SIGNING_KEY and REFRESH_KEY from environment variables automatically.
import { setupEmailAuth } from "naystack/auth";
import { db } from "@/app/api/lib/db";
import { UserTable } from "@/app/api/(graphql)/User/db";
import { eq } from "drizzle-orm";
export const { GET, POST, PUT, DELETE } = setupEmailAuth({
// Fetch user by request data (used for login & sign-up duplicate check)
getUser: async ({ email }: { email: string }) => {
const [user] = await db
.select({ id: UserTable.id, password: UserTable.password })
.from(UserTable)
.where(eq(UserTable.email, email));
return user;
},
// Create a new user with the hashed password
createUser: async (data: {
email: string;
password: string;
name: string;
}) => {
const [user] = await db
.insert(UserTable)
.values(data)
.returning({ id: UserTable.id, password: UserTable.password });
return user;
},
// Optional: callback after successful sign-up
onSignUp: async (userId, body: { orgTitle?: string }) => {
if (body.orgTitle && userId) {
await createOrg(userId, { title: body.orgTitle });
}
},
});The returned route handlers map to:
| Handler | HTTP Method | Purpose |
| -------- | ----------- | ----------------------------------------- |
| GET | GET | Refresh tokens (exchange refresh cookie) |
| POST | POST | Sign up (create user, return tokens) |
| PUT | PUT | Login (verify credentials, return tokens) |
| DELETE | DELETE | Logout (clear refresh cookie) |
Client Setup
Wrap your application with AuthWrapper in your root layout. This fetches the access token on mount and provides it to all auth hooks via React context.
// app/layout.tsx
import { AuthWrapper } from "naystack/auth/client";
import { ApolloWrapper } from "naystack/graphql/client";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthWrapper>
<ApolloWrapper>{children}</ApolloWrapper>
</AuthWrapper>
</body>
</html>
);
}Frontend Hooks
useToken()
Returns the current JWT access token (or null if not loaded / logged out). Use it for conditional rendering or passing to custom fetch calls.
import { useToken } from "naystack/auth/client";
export default function Home() {
const token = useToken();
return (
<Link href={token ? "/dashboard" : "/signup"}>
<button>{token ? "Dashboard" : "Get Started"}</button>
</Link>
);
}useSignUp()
Returns a function that registers a new user. Sends a POST to the auth endpoint. Returns null on success, or the error message string on failure.
import { useSignUp } from "naystack/auth/client";
function SignUpForm() {
const signUp = useSignUp();
const handleSubmit = async (data: {
name: string;
email: string;
password: string;
}) => {
const error = await signUp(data);
if (error) {
setMessage(error);
} else {
router.replace("/dashboard");
}
};
}useLogin()
Returns a function that logs the user in. Sends a PUT to the auth endpoint. Returns null on success, or the error message string on failure.
import { useLogin } from "naystack/auth/client";
function LoginForm() {
const login = useLogin();
const handleSubmit = async (data: { email: string; password: string }) => {
const error = await login(data);
if (error) {
form.setError("password", { message: error });
} else {
router.replace("/dashboard");
}
};
}useLogout()
Returns a function that logs the user out. Clears the token immediately and sends DELETE to the auth endpoint.
import { useLogout } from "naystack/auth/client";
function LogoutButton() {
const logout = useLogout();
return (
<button
onClick={() => {
logout();
router.push("/login");
}}
>
Log out
</button>
);
}Server-side Auth Helpers
getContext(req)
Extracts the auth context from a NextRequest. Reads either the Authorization: Bearer <token> header or the refresh cookie. Use it in API routes outside of GraphQL.
import { getContext } from "naystack/auth";
export const POST = async (req: NextRequest) => {
const ctx = getContext(req);
if (!ctx?.userId) return new NextResponse("Unauthorized", { status: 401 });
// ctx.userId is available for authenticated operations
const chats = await db
.select()
.from(ChatTable)
.where(eq(ChatTable.userId, ctx.userId));
return NextResponse.json(chats);
};getRefreshToken()
Server-side function to read the refresh token from cookies. Useful in Server Components and layouts to check if the user is logged in.
import { getRefreshToken } from "naystack/auth";
import { redirect } from "next/navigation";
export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
const token = await getRefreshToken();
if (!token) return redirect("/login");
return <div>{children}</div>;
}checkAuthStatus(redirectURL?)
Checks if the current request has a valid refresh cookie. Optionally redirects to the given URL if not authorized.
import { checkAuthStatus } from "naystack/auth";
// In a Server Component:
await checkAuthStatus("/login"); // Redirects to /login if not authorizedGoogle OAuth
import { setupGoogleAuth } from "naystack/auth";
export const { GET } = setupGoogleAuth({
getUserIdFromEmail: async (googleUser) => {
// Find or create user by Google email
return findOrCreateUserByEmail(googleUser.email!);
},
redirectURL: "/dashboard",
errorRedirectURL: "/login",
});Connecting social accounts
Linking an Instagram (or any other platform) account to an already logged-in user
is not login — it lives in §5 Social APIs as setupSocialAuth.
2. GraphQL
Naystack provides a type-safe GraphQL layer built on type-graphql and Apollo Server. Define resolvers as plain functions and let the library generate the schema.
Defining Queries and Mutations
Use resolver() to define a resolver. It returns an object with the resolver function, plus .call() and .authCall() for direct server-side invocation (e.g. in Server Components).
// app/api/(graphql)/User/resolvers/get-current-user.ts
import { resolver } from "naystack/graphql";
export default resolver(
async (ctx) => {
if (!ctx.userId) return null;
const [user] = await db
.select()
.from(UserTable)
.where(eq(UserTable.id, ctx.userId));
return user || null;
},
{
output: User, // GraphQL return type (type-graphql class)
outputOptions: { nullable: true }, // Return type is nullable
},
);With input and authorization:
// app/api/(graphql)/Feedback/resolvers/submit-feedback.ts
import { resolver } from "naystack/graphql";
export default resolver(
async (ctx, input: SubmitFeedbackInput) => {
await db.insert(FeedbackTable).values({
userId: ctx.userId, // guaranteed non-null when authorized: true
score: input.score,
text: input.text,
});
return true;
},
{
output: Boolean,
input: SubmitFeedbackInput, // GraphQL input type (type-graphql @InputType class)
authorized: true, // Requires authenticated user (ctx.userId non-null)
mutation: true, // Registers as a Mutation (default is Query)
},
);Defining Field Resolvers
Use field() to define resolvers for computed fields on a parent type. The first argument is the parent object.
// app/api/(graphql)/Property/resolvers/seller-field.ts
import { field } from "naystack/graphql";
export default field(
async (property: PropertyDB) => {
if (!property.sellerId) return null;
const [seller] = await db
.select()
.from(ContactTable)
.where(eq(ContactTable.id, property.sellerId));
return seller || null;
},
{
output: ContactGQL,
outputOptions: { nullable: true },
},
);Registering Resolvers
Use QueryLibrary() for queries/mutations and FieldLibrary() for field resolvers. Pass the returned classes to setupGraphQL.
// app/api/(graphql)/User/graphql.ts
import { QueryLibrary, FieldLibrary } from "naystack/graphql";
import getCurrentUser from "./resolvers/get-current-user";
import onboardUser from "./resolvers/onboard-user";
import updateUser from "./resolvers/update-user";
import organizations from "./resolvers/organizations-field";
import { User } from "./types";
// Each key becomes a Query or Mutation field name in the schema
export const UserResolvers = QueryLibrary({
getCurrentUser,
onboardUser,
updateUser,
});
// Each key becomes a field resolver on the User type
export const UserFieldResolvers = FieldLibrary<UserDB>(User, {
organizations,
});Initializing the GraphQL Server
// app/api/(graphql)/route.ts
import { setupGraphQL } from "naystack/graphql";
import { UserResolvers, UserFieldResolvers } from "./User/graphql";
import { ChatResolvers } from "./Chat/graphql";
import { FeedbackResolvers } from "./Feedback/graphql";
export const { GET, POST } = await setupGraphQL({
resolvers: [
UserResolvers,
UserFieldResolvers,
ChatResolvers,
FeedbackResolvers,
],
});The getContext function is built in — it reads the Authorization header or refresh cookie automatically. Pass a custom getContext if you need to override it.
Throwing Errors
Use GQLError() to throw structured GraphQL errors from resolvers:
import { GQLError } from "naystack/graphql";
// In a resolver:
if (!input.email) throw GQLError(400); // "Please provide all required inputs"
if (!ctx.userId) throw GQLError(403); // "You are not allowed to perform this action"
if (!deal) throw GQLError(404, "Deal not found"); // Custom messageType Helpers: QueryResponseType / FieldResponseType
Infer the return type of a query or field resolver definition. Use them to type component props that receive resolver results.
import type { QueryResponseType, FieldResponseType } from "naystack/graphql";
import type getCurrentUser from "@/app/api/(graphql)/User/resolvers/get-current-user";
import type getDeal from "@/app/api/(graphql)/Deal/queries/get-deal";
import type sellerField from "@/app/api/(graphql)/Property/resolvers/seller-field";
interface DealDetailsProps {
user: QueryResponseType<typeof getCurrentUser>;
deal: QueryResponseType<typeof getDeal>;
seller: FieldResponseType<typeof sellerField>;
}Server-Side Data Fetching
Direct calls with .call() / .authCall()
Every query definition has .call() (unauthenticated or based on authorized flag) and .authCall() (always reads the refresh cookie for auth). Use these in Server Components.
// In a Server Component:
const user = await getCurrentUser.authCall();
const planets = await getPlanets.authCall();Injector Component
Wraps a client component and injects server-fetched data via Suspense. The component receives { data, loading } as props.
// app/(dashboard)/chat/page.tsx
import { Injector } from "naystack/graphql/server";
import getCurrentUser from "@/app/api/(graphql)/User/resolvers/get-current-user";
import getChats from "@/app/api/(graphql)/Chat/resolvers/get-chats";
import { ChatWindow } from "./components/chat-window";
export default async function ChatPage() {
return (
<Injector
fetch={async () => {
const user = await getCurrentUser.authCall();
const chats = await getChats.authCall();
return { user, chats };
}}
Component={ChatWindow}
/>
);
}The ChatWindow component receives { data, loading }:
// components/chat-window.tsx
export function ChatWindow({ data, loading }: { data?: { user: ...; chats: ... }; loading: boolean }) {
if (loading) return <Spinner />;
return <div>{data?.user.name}'s chats: {data?.chats.length}</div>;
}Server-side query() (from naystack/graphql/server)
Run a raw GraphQL query on the server using the registered Apollo client. Cookies are sent automatically.
import { query } from "naystack/graphql/server";
const data = await query(GetUserDocument, {
variables: { id: userId },
revalidate: 60, // Cache for 60s (Next.js ISR)
tags: ["user"], // For on-demand revalidation
});Client Setup (Apollo)
Wrap your app with ApolloWrapper (inside AuthWrapper) so client components can use GraphQL hooks:
// app/layout.tsx
import { AuthWrapper } from "naystack/auth/client";
import { ApolloWrapper } from "naystack/graphql/client";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthWrapper>
<ApolloWrapper>{children}</ApolloWrapper>
</AuthWrapper>
</body>
</html>
);
}Client Hooks
useAuthQuery(query, variables?)
Hook to run a GraphQL query with the current user's token. Returns [refetch, { data, loading, error }].
import { useAuthQuery } from "naystack/graphql/client";
import { GET_SUMMARY } from "@/constants/graphql/queries";
function SummaryCard({ type }: { type: string }) {
const [getSummary, { loading, data }] = useAuthQuery(GET_SUMMARY);
const handleFetch = async () => {
const result = await getSummary({ type });
if (result.data?.getSummary) {
setSummary(result.data.getSummary);
}
};
return (
<button onClick={handleFetch} disabled={loading}>
Get Summary
</button>
);
}useAuthMutation(mutation, options?)
Hook to run a GraphQL mutation with the current user's token. Returns [mutate, { data, loading, error }].
import { useAuthMutation } from "naystack/graphql/client";
import { CREATE_DEAL } from "@/lib/gql/mutations";
function CreateDealModal({ propertyId }: { propertyId: number }) {
const [createDeal, { loading }] = useAuthMutation(CREATE_DEAL);
const onSubmit = async (values: FormFields) => {
const response = await createDeal({
propertyId,
share: Number(values.share),
targetProfit: Number(values.targetProfit),
});
const dealId = response.data?.createDeal;
if (dealId) router.push(`/deals/${dealId}`);
};
return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
}3. File Upload
Naystack simplifies AWS S3 file uploads with presigned URLs and client-side helpers. AWS credentials are read from environment variables automatically.
Server Setup
// app/api/(rest)/file/route.ts
import { setupFileUpload } from "naystack/file";
export const { PUT } = setupFileUpload({
// Called after each successful upload. Return value is sent in the response as `onUploadResponse`.
onUpload: async ({ url, type, userId, data }) => {
if (type === "DealDocument" && url) {
const payload = data as {
dealId: number;
fileName: string;
category: string;
};
const [row] = await db
.insert(DealDocumentsTable)
.values({
dealId: payload.dealId,
fileURL: url,
fileName: payload.fileName,
category: payload.category,
})
.returning();
return row ?? {};
}
return {};
},
// Optional: customize the S3 key (defaults to UUID)
getKey: async ({ type, userId }) =>
`${type}/${userId}/${crypto.randomUUID()}`,
});The setupFileUpload also returns server-side helpers:
uploadFile(keys, { url?, blob? })— Upload a file from a URL or Blob to S3.deleteFile(url)— Delete a file by its full S3 URL.getUploadURL(keys)— Get a presigned PUT URL.getDownloadURL(keys)— Get the public download URL.
Client Usage
import { useFileUpload } from "naystack/file/client";
function FileUploader({ dealId }: { dealId: number }) {
const uploadFile = useFileUpload();
const [uploading, setUploading] = useState(false);
const handleUpload = async (file: File) => {
setUploading(true);
try {
const result = await uploadFile(file, "DealDocument", {
data: { dealId, fileName: file.name, category: "Contract" },
});
if (result?.url) {
console.log("Uploaded:", result.url);
router.refresh();
}
} finally {
setUploading(false);
}
};
return (
<input
type="file"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
/>
);
}4. Client Utilities
SEO
The setupSEO utility creates a metadata factory for Next.js. Call it once with your site defaults, then use the returned function per-page.
// lib/utils/seo.ts
import { setupSEO } from "naystack/client";
export const getSEO = setupSEO({
title: "My App - Tagline",
description: "Description of my application.",
siteName: "My App",
themeColor: "#5b9364",
});
// In a page:
export const metadata = getSEO("Dashboard", "Your personalized dashboard");
// Produces: title = "Dashboard • My App", description = "Your personalized dashboard"useVisibility(onVisible?)
Triggers a callback when a DOM element enters the viewport. Returns a ref to attach to the observed element.
import { useVisibility } from "naystack/client";
function LazySection() {
const ref = useVisibility(() => loadMoreData());
return <section ref={ref}>...</section>;
}useBreakpoint(query)
Responsive media query hook. Returns true/false or null during SSR.
import { useBreakpoint } from "naystack/client";
function ResponsiveNav() {
const isMobile = useBreakpoint("(max-width: 639px)");
if (isMobile === null) return <Skeleton />;
return isMobile ? <MobileNav /> : <DesktopNav />;
}5. Social APIs
Publish to and read from Instagram and Threads, and read any connected account through one normalized provider interface.
Each platform exposes one publishing method that picks the right media type from what you pass. Meta's container/upload/publish dance underneath is an implementation detail — you never touch it.
| Path | What it is |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| socials/types.ts | Platform-neutral shapes: SocialProfile, SocialPost, SocialTokens, SocialPlatform, SocialMediaKind. Imported from naystack/socials/types — their own entry, deliberately not re-exported by the barrel (see below), and free of server code so client bundles can use it |
| socials/provider.ts | The SocialProvider interface |
| socials/poll.ts | pollUntilReady / withRetry — waiting and retrying, with no platform in them |
| socials/meta/ | Meta's Graph protocol — request client, container polling, webhook verification. Shared by Instagram and Threads only |
| socials/instagram/, socials/threads/ | One folder per platform: getters, setters, types, and an adapter.ts for platforms that implement SocialProvider |
Reading is normalized; publishing is not. OAuth, profile and content endpoints
collapse cleanly onto one shape, so a consumer stores one row per connected account
whatever the platform. Publish inputs don't: Threads takes text-only posts,
Instagram requires media and adds stories, reels, carousels and collaborators. A
shared publish signature could only describe the intersection, so each platform
keeps its own create<Platform>Post.
A platform that isn't on Meta's Graph API (X, LinkedIn) brings its own client and
exposes its own create<Platform>Post; it doesn't touch socials/meta/. If it also
supports OAuth + profile reads, it adds an adapter.ts exporting a SocialProvider
constant so the generalized pieces pick it up for free.
Providers
A provider is a plain object with no per-instance state — import the constant, no
construction. Capability is method presence: a platform that can't refresh tokens
omits refresh, one with no content-listing API omits fetchMedia, and callers
narrow with a plain if. Platform knowledge is not a capability, so it is
always there: platform and profileURL(username) are required of every adapter.
The shapes come from naystack/socials/types, never from naystack/socials:
import { InstagramProvider } from "naystack/socials";
import { SocialPlatform } from "naystack/socials/types";The build runs with splitting: false, so the barrel inlines its own copy of
that module. Were it to re-export the enums, a consumer mixing the two paths
would hold two enum objects with identical values — fine for ===, fatal for
anything identity-keyed (type-graphql's enum registry answers "Cannot determine
GraphQL input type" and names neither module). One path, one instance. Same
reasoning as auth/token-store.
import { InstagramProvider } from "naystack/socials";
const profile = await InstagramProvider.fetchProfile(accessToken);
// { platform: "instagram", username: "…", followers: 12043, contentCount: 87, … }
const posts = await InstagramProvider.fetchMedia?.(accessToken, { limit: 6 });
// [{ kind: "video", permalink: "…", likes: 812, comments: null, … }]
// null instead means the request failed — an account with nothing posted
// returns []. Cache the empty, retry the null.
InstagramProvider.profileURL(profile.username);
// "https://instagram.com/…" — so a stored account row links out without the
// consumer keeping its own per-platform URL table. Code holding a row rather
// than a provider — a client bundle, a query result — calls the same table
// directly: `socialProfileURL(platform, username)` from `naystack/socials/types`.Every metric on a SocialPost is number | null, and null always means the
platform didn't give us this — hidden, unrequested, or nonexistent on that
platform. Adapters never substitute 0, because "nobody liked it" and "likes are
hidden" are different facts and only you know which your averages should skip.
A list of providers is all a job needs to stay platform-agnostic:
const providers = [InstagramProvider];
for (const account of expiringAccounts) {
const provider = providers.find((p) => p.platform === account.platform);
const next = await provider?.auth.refresh?.({ accessToken: account.accessToken });
}Connecting accounts
setupSocialAuth mounts every registered provider's OAuth flow behind one route.
Mount it on a dynamic segment and /api/social/instagram, /api/social/youtube
and the rest are all the same handler. The single GET serves both the connect entry
point (?state, no code → 302 to the platform's authorize URL) and the callback.
// app/api/social/[platform]/route.ts
import { InstagramProvider, setupSocialAuth } from "naystack/socials";
export const { GET } = setupSocialAuth({
providers: [InstagramProvider],
endpoint: "https://yourapp.com/api/social",
redirectURL: "/dashboard",
errorRedirectURL: "/login",
onConnect: async ({ platform, profile, tokens, userId }) => {
if (!userId) return "You are not logged in";
await saveSocialAccount(userId, platform, profile, tokens);
},
// Optional, per platform. Each provider has its own default.
scopes: { instagram: ["instagram_business_basic"] },
});onConnect receives a normalized SocialProfile, so one callback stores every
platform. Each provider's redirect URI is <endpoint>/<platform>; platforms match
it exactly, so register every one of them.
The Instagram OAuth primitives underneath — getInstagramAuthorizationURL,
getLongLivedInstagramToken, refreshInstagramAccessToken — are exported for
anything the route doesn't cover. Requires INSTAGRAM_CLIENT_ID and
INSTAGRAM_CLIENT_SECRET.
createInstagramPost(token, input) — feed image, reel, story or carousel:
| media | Result |
| ----------------------- | ----------------------------- |
| one image | feed image |
| one video | reel |
| 2–10 items | carousel (counts as one post) |
| any, with story: true | story, from the first item |
import { createInstagramPost } from "naystack/socials";
// Feed image — JPEG only
await createInstagramPost(accessToken, {
media: { url: "https://cdn.example.com/launch.jpg", type: "image" },
caption: "New campaign is live 🎉",
});
// Reel — 9:16, 5–90s, H.264/HEVC to reach the Reels tab
await createInstagramPost(accessToken, {
media: { url: "https://cdn.example.com/promo.mp4", type: "video" },
caption: "Behind the scenes",
shareToFeed: true,
thumbOffset: 1500, // or coverURL
});
// Story — gone in 24h
await createInstagramPost(accessToken, {
media: { url: "https://cdn.example.com/story.mp4", type: "video" },
story: true,
});
// Carousel
await createInstagramPost(accessToken, {
caption: "Campaign recap",
media: [
{ url: "https://cdn.example.com/1.jpg", type: "image" },
{ url: "https://cdn.example.com/2.mp4", type: "video" },
],
});canPublishToInstagram(token) answers whether a token is allowed to publish. Meta
exposes no scope-listing endpoint for Instagram Login tokens, so it probes the
publishing quota — a read-only endpoint needing exactly the same
instagram_business_content_publish scope. Nothing is posted:
import { canPublishToInstagram } from "naystack/socials";
if (!(await canPublishToInstagram(process.env.INSTAGRAM_ACCESS_TOKEN!))) {
throw new Error(
"Instagram token cannot publish — reconnect with the publish scope",
);
}It rejects rather than answering false if the request itself fails, so a network blip
is never reported as a missing permission. For a one-off manual check, Meta's
Access Token Debugger shows
a token's scopes in the browser.
createInstagramPost returns the published media id, or null if any step failed — the
API's own error is logged. Media URLs must be publicly reachable (Instagram downloads them server-side)
and images must be JPEG. Publishing needs a token with the
instagram_business_content_publish scope and an Instagram professional account;
Instagram allows 100 published posts per rolling 24 hours. Tune container polling with
wait: { intervalMS, timeoutMS }.
Reading and messaging:
import {
getInstagramUser,
getInstagramMedia,
getInstagramConversations,
getInstagramConversation,
getInstagramConversationByUser,
getInstagramConversationsByUser,
getInstagramMessage,
sendInstagramMessage,
setupInstagramWebhook,
} from "naystack/socials";
// Fetch the authenticated user's profile
const user = await getInstagramUser(accessToken);
// => { username: "johndoe", followers_count: 1234, media_count: 56 }
// Fetch recent media
const media = await getInstagramMedia(accessToken, undefined, 10);
// => { data: [{ like_count: 5, comments_count: 2, permalink: "..." }, ...] }
// Fetch conversations with pagination
const convos = await getInstagramConversations(accessToken, 25);
for (const convo of convos.data ?? []) {
console.log(convo.participants, convo.messages);
}
if (convos.fetchMore) {
const nextPage = await convos.fetchMore();
}
// Fetch conversations filtered by a specific user
const userConvos = await getInstagramConversationsByUser(accessToken, userId);
// Fetch the single conversation with a specific user (2-participant thread)
const convo = await getInstagramConversationByUser(accessToken, userId);
// Send a message
await sendInstagramMessage(accessToken, recipientId, "Hello!");
// Webhook setup (app/api/webhooks/instagram/route.ts)
export const { GET, POST } = setupInstagramWebhook({
secret: process.env.WEBHOOK_SECRET!,
callback: async (type, value, id) => {
console.log("Webhook event:", type, value, id);
},
});Threads
createThreadsPost(token, input) — no media is a text post, one item is a single
image or video, 2–20 is a carousel:
import {
getThread,
getThreads,
getThreadsReplies,
createThreadsPost,
createThread,
setupThreadsWebhook,
} from "naystack/socials";
// Text post — a bare string works
await createThreadsPost(accessToken, "Hello from Naystack!");
// Media, replies, reply controls
await createThreadsPost(accessToken, {
text: "Campaign is live",
media: { url: "https://cdn.example.com/campaign.jpg", type: "image" },
replyControl: "everyone",
});
// Carousel
await createThreadsPost(accessToken, {
text: "Campaign recap",
media: [
{ url: "https://cdn.example.com/1.jpg", type: "image" },
{ url: "https://cdn.example.com/2.mp4", type: "video" },
],
});
// A thread — each post replies to the previous one
const firstPostId = await createThread(accessToken, [
"First post in thread",
"Second post (reply to first)",
{
text: "Third, with a picture",
media: { url: "https://cdn.example.com/3.jpg", type: "image" },
},
]);
// Fetch user's threads
const threads = await getThreads(accessToken);
// => [{ text: "Hello world", permalink: "...", username: "johndoe" }]
// Webhook setup (app/api/webhooks/threads/route.ts)
export const { GET, POST } = setupThreadsWebhook({
secret: process.env.WEBHOOK_SECRET!,
callback: async (field, value) => {
console.log("Threads event:", field, value);
return true; // Return false to respond with 500
},
});Environment Variables
Naystack reads configuration from environment variables. Set the ones you need based on which modules you use.
Required (Core Auth)
SIGNING_KEY=your-jwt-signing-key
REFRESH_KEY=your-jwt-refresh-keyRequired (Client-side endpoints)
NEXT_PUBLIC_EMAIL_AUTH_ENDPOINT=/api/email
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/graphql
NEXT_PUBLIC_FILE_ENDPOINT=/api/file
NEXT_PUBLIC_BASE_URL=https://yourapp.comGoogle OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
NEXT_PUBLIC_GOOGLE_AUTH_ENDPOINT=/api/googleSocial accounts
NEXT_PUBLIC_SOCIAL_AUTH_ENDPOINT is the base URL of the setupSocialAuth route;
each provider's redirect URI is <base>/<platform>. Client ids are server-only —
the authorization URL is built on the server, so they never ship in the client bundle.
NEXT_PUBLIC_SOCIAL_AUTH_ENDPOINT=/api/social
INSTAGRAM_CLIENT_ID=your-instagram-client-id
INSTAGRAM_CLIENT_SECRET=your-instagram-client-secretAWS S3 (File Upload)
S3_REGION=us-east-1
S3_BUCKET=your-bucket-name
S3_ACCESS_KEY_ID=your-access-key-id
S3_ACCESS_KEY_SECRET=your-secret-access-keyOptional
TURNSTILE_KEY=cloudflare-turnstile-secret-key
NODE_ENV=production